Repository: BloopAI/vibe-kanban Branch: main Commit: a05fff227c17 Files: 1782 Total size: 8.6 MB Directory structure: gitextract_ei47u9gy/ ├── .cargo/ │ └── config.toml ├── .dockerignore ├── .github/ │ ├── actions/ │ │ ├── cargo-checks-common-setup/ │ │ │ └── action.yml │ │ ├── setup-jsign/ │ │ │ └── action.yml │ │ └── setup-node/ │ │ └── action.yml │ └── workflows/ │ ├── pre-release.yml │ ├── publish.yml │ ├── relay-deploy-dev.yml │ ├── relay-deploy-prod.yml │ ├── relay-release.yml │ ├── remote-deploy-dev.yml │ ├── remote-deploy-prod.yml │ ├── remote-release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── AGENTS.md ├── CODE-OF-CONDUCT.md ├── CONTRIBUTORS.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── assets/ │ └── scripts/ │ └── toast-notification.ps1 ├── crates/ │ ├── api-types/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── attachment.rs │ │ ├── auth.rs │ │ ├── blob.rs │ │ ├── issue.rs │ │ ├── issue_assignee.rs │ │ ├── issue_comment.rs │ │ ├── issue_comment_reaction.rs │ │ ├── issue_follower.rs │ │ ├── issue_relationship.rs │ │ ├── issue_tag.rs │ │ ├── lib.rs │ │ ├── migration.rs │ │ ├── notification.rs │ │ ├── oauth.rs │ │ ├── organization_member.rs │ │ ├── organizations.rs │ │ ├── project.rs │ │ ├── project_status.rs │ │ ├── pull_request.rs │ │ ├── pull_requests_local.rs │ │ ├── relay.rs │ │ ├── response.rs │ │ ├── tag.rs │ │ ├── user.rs │ │ ├── workspace.rs │ │ └── workspaces.rs │ ├── db/ │ │ ├── .sqlx/ │ │ │ ├── query-039c2290b6cf7cdc905c8ddc44293f067fe7e8f246da737e4baad3f494ac8b8f.json │ │ │ ├── query-04c207be2c3c2c07ff42c695542504c358d67c1f40ca2b1e75a396a90c173a53.json │ │ │ ├── query-04e5a05c7cad438d39c4c8590410889ab1eefa7376d474a10c119d3f4d9143c7.json │ │ │ ├── query-04f17449e3e12785affab91e4eab308103491e34c022199b7b060e04fa8aed0f.json │ │ │ ├── query-0a805c219c9028b2677bd94ccabd47916e60d26c1cede27e467f0ae91f6639ab.json │ │ │ ├── query-0ab07fb562e61148f3f07f33f766ea12c73d467df4522240008370f681c8409a.json │ │ │ ├── query-0ac0d0f3826330836e3fd1bf57c42777eb489ac41a650f9361e6b563fc69bf35.json │ │ │ ├── query-0c7b20643f119afd3e233105b0fa2920e8e940bdad86cdc95d01e485a20d6ed4.json │ │ │ ├── query-0f90844fc62261ed140e02515ae464b940743113814507313c9fdc176000d1bf.json │ │ │ ├── query-1085d1f8107c7e16fc2058ef610918760d8d420f0fca97adecd76d698f6f3a51.json │ │ │ ├── query-11793c98a4bee67fce9972ed6b10a18226e0455a0e8d113d04c4d5148b72aec7.json │ │ │ ├── query-12a5c0a8b95d8cb87f1c869ff35692a2cee52bc418b06d00a31a4c139e12d18a.json │ │ │ ├── query-13826fc6fdd367255cb921640e5972f30905ac7a81ad477cf8bbcfc24f06f39b.json │ │ │ ├── query-167c1d13ee37ebe62cd2316feaf6b5354eb26d0a8fc16efb22827a5cde59a60e.json │ │ │ ├── query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json │ │ │ ├── query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json │ │ │ ├── query-218f1d14c72148ea88d75e816e1ba111c8f4678a7e428b15462e6dfc74c25b03.json │ │ │ ├── query-2547a5d06fd3b17360bff34a04b7d3d929c13ef0d86395a9201834d8fc955295.json │ │ │ ├── query-256f9e937384933464e6d4d00ee977bbb2915ef80930c8b5c0b0525367a5264d.json │ │ │ ├── query-29cc3aa8d0ad5deda94494402500a4125e29381d63f18ef083cc4da95e2c5db5.json │ │ │ ├── query-2a57b702e52b3cc9bdbc361267985958b11d4493b01a9ab8daedf5d951422897.json │ │ │ ├── query-2b253f92ac5daa4864e7335fde1b82625f504fd73d19b21992497219a9c3170a.json │ │ │ ├── query-2c0172d5b2c5bff0914727a57983d5c336f5b2dfa73ca6c2efa4ea23bb526e05.json │ │ │ ├── query-2c71bf4dd5683e0dedf2341e52880ff2c0765659d3cf53d62faa54adc91071dd.json │ │ │ ├── query-2cb5a269045f23da9f4ee0ee679ccb7fffc39d4b37b1b58357b11a7abfdba125.json │ │ │ ├── query-31f4e685fc0b1103ff662b3866b3bb422cc7fc8e0661ebfed30ffd16ea7ed8c0.json │ │ │ ├── query-3266b6a544952177f84e2e7c31be9dba212c92d91b997de7f6aa811e08ed6c72.json │ │ │ ├── query-33f23656ba343bd75a88b0fadf2a4ba01eda330f9b549e625e27701e3b0b5a31.json │ │ │ ├── query-3634e2bab8fef106721bb64a791edd81d3d49eb34fbabd34e4feadfb5f229a6e.json │ │ │ ├── query-3a9148e9e914d644d4d82a1c2dc8bd0e093d4f4c638afa7fd8f5211892fb6d84.json │ │ │ ├── query-3ace1ee8dba0669400d69891912b86823e41ca643092d990b12c1a6160112427.json │ │ │ ├── query-3d34580933bc02a168f4a7c483460a6ad13ef72b508532f5a6cd5e53aff04a69.json │ │ │ ├── query-3d85256618729c1c0bf2758ffab9cdb4ec2af0751e3a37db4009c02f95f6f556.json │ │ │ ├── query-3f4b2179dcd8857fc18f1e5f6e6cf10f152eebbb141b2d3604dd4191e0c2f367.json │ │ │ ├── query-420a0c490f1e4d549fb194265e971d8c03b86fe75b595091f6425de11d120a6b.json │ │ │ ├── query-4506eeeb85b49f00143a9d23636c976159e1c72c9cfce005599c5eb52dc15095.json │ │ │ ├── query-48c556a5317e6ea77595a8fdc410d30df50c8405adf38b371fdf0a1bde8c0083.json │ │ │ ├── query-4ac35216ead7e5be9cc2de504a06b6e375e23ca2ed14493ec991f53e458a6a34.json │ │ │ ├── query-4b59b958807be54c7c9949d96ced96e4ab1498f1056e7d0d7956aff46352d90f.json │ │ │ ├── query-4b952fb779fbcf70bd402b6bcc0eec07b75879333614b8ef98e5b8073ad66ca6.json │ │ │ ├── query-4c9b1b539ec383ace94ef29c58967bbf08112ebdc61276e9710663a083318211.json │ │ │ ├── query-4d84b308a2cc7677da65111d080bf02e5e35c052048360d3dbea656bbbcd3edb.json │ │ │ ├── query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json │ │ │ ├── query-5154289c5a9ddbc061d42de2baf129e03a75061b8b305110921688f01d112de1.json │ │ │ ├── query-517fb82570b9624e166696af53963ec499966562b23b5833fc4ca4cf43bcaccc.json │ │ │ ├── query-557963b950205b10db273762da5fd24c9db96c1f366a796c319e4adc888d7414.json │ │ │ ├── query-570f62a32921c4a9a7e4e1006e9b31c4c58e69ab8681d76dfe9b184ff1e0bc65.json │ │ │ ├── query-5785a10c3d51ff9b001aa455f6296a4dcba61cec700a4b72031c5c643b273938.json │ │ │ ├── query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json │ │ │ ├── query-586d5ab95899967c8a8f74996c4a598a73661823bc2bcb240cebb7cd0533abb6.json │ │ │ ├── query-5884e7baa4a061166cb2911f717d3fd92852d62975a910dd9cb05e7908fdf8b6.json │ │ │ ├── query-592656b17a5f78d117365909e47afba7d3df545ac1078c307c6b968e75c8e2ba.json │ │ │ ├── query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json │ │ │ ├── query-5d9739705372b113b1bb45d441ebdf2846dc4cd83b8547128c733cd282b5b4f2.json │ │ │ ├── query-5ff9809face43fe1f071dfda62b6a30f4a32a9aaace29caf89b95c224482201b.json │ │ │ ├── query-606016bf31cf0abd87d7eb95ea99d7c8c014523cb02e482d608299ceefaeffc5.json │ │ │ ├── query-61c6546164ba21a659d32d4e345926b0ee1a611fe4e46bb8db51a4e41f781af9.json │ │ │ ├── query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json │ │ │ ├── query-686810c5271e1d44042b6ea2c6cc434eb2e3f5d3540c97d703c34dd4e978c690.json │ │ │ ├── query-6ae5eb2719382d4d081ee17dbd5de654c156b06e2af4ddfb917d36002146be5b.json │ │ │ ├── query-70b21c5c0a2ba5c21c9c1132f14a68c02a8a2cd555caea74e57a0aeb206770d3.json │ │ │ ├── query-7364150098bec681451c43762117a1f5c5b4e27f5f65186c3cc16092b3491c37.json │ │ │ ├── query-73aee4cb95294087554eafaf3126556df244f4b6639d5a188f0badb6739c1a70.json │ │ │ ├── query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json │ │ │ ├── query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json │ │ │ ├── query-7807cb09da72c5a1e35bf4f4da1bea1743a578588e72444ede98f5f969af08c1.json │ │ │ ├── query-784e59a5259f046a74bbfd3cc5a78500797ccf3e67928e5f1520623c5c04ac9f.json │ │ │ ├── query-79e1e11b83c786c6d5a985ab045b6bd122d5efa920225dadc9fedb6592c6e0a3.json │ │ │ ├── query-79f6b9f999c33900ae87475d72651b274cc94ab3b1f36e9c5517bc5572ea9947.json │ │ │ ├── query-7d12bf106e68365fc1aa239b8b39065430f30ad658d0bf9801c81e3ced2127da.json │ │ │ ├── query-80669005bff96b45015f095ccf28598df604540e2aaf3828fcb8db7d55538dc7.json │ │ │ ├── query-80e6cfb4e27fcaa79b7dbd37ac16aac255f46a646c75aa65111b2f58ec03f892.json │ │ │ ├── query-82b92577d15b92908cecca7bff6ff21478c90a16eb6123165f0bd16d2d60bca4.json │ │ │ ├── query-82f7bba858a26732ad9d4122c3a0bca4209ae37c59dcd7353a68e2dec434c48a.json │ │ │ ├── query-84ee994f0aad005cf62ca318eb20ae29d218a72cdd1fadf2a5ae399b0719ca19.json │ │ │ ├── query-891bad4b14f75be20c70a5cec02fb3b4fb3cbb84ce322bf5da3791d75b1deae7.json │ │ │ ├── query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json │ │ │ ├── query-9139f8d02c4ff94db0f2e8de7a6d5a53092479499815531962b7c84f5e0b2129.json │ │ │ ├── query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json │ │ │ ├── query-93efe07b91d232fc0c371be8ee618ba6ccfd6930454fc11845d5dfc2ba0bad62.json │ │ │ ├── query-973e43902b05d671f69b24a0aeeb07bc0cbcd22d75b20c83c49a122f92c6b231.json │ │ │ ├── query-9747ebaebd562d65f0c333b0f5efc74fa63ab9fcb35a43f75f57da3fcb9a2588.json │ │ │ ├── query-9821ee63362e96cf3fd936e2d54a641fb30f239a8137dc6c1b3a670b2c6138c1.json │ │ │ ├── query-99399425f53b140a8de232a4de3c6c056bc422f2fbdb8ead6aab3f6945906e51.json │ │ │ ├── query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json │ │ │ ├── query-9f783d1b275548a59429235991e5299b7aaf071effebbd62f006404b3ce83dc8.json │ │ │ ├── query-9f8ab7d7c2660321412117bfb55e3b2b9ccd7b9ed2679fb8ccca0a36996e6e21.json │ │ │ ├── query-a1574f21db387b0e4a2c3f5723de6df4ee42d98145d16e9d135345dd60128429.json │ │ │ ├── query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json │ │ │ ├── query-a4a50fcfb903e6d0a315676f4f760e5bb7718e10ea550aedf990c9da84834416.json │ │ │ ├── query-a915c22f5ed0bb86c3a242ca38cbc1bfca40ebfe9096058c27e94479b67c7c02.json │ │ │ ├── query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json │ │ │ ├── query-aa598f6943fbf773ca00deb113f3955bdf689d1c22df63849bc5ce36c7c76382.json │ │ │ ├── query-ab2693e557142354564a2882f3c321b350828419c440885c0f88840079b1c94e.json │ │ │ ├── query-abbda92ba42bea0f7d17d0945d51b011bf50e7b36ee50ed74988e053f6fb0eec.json │ │ │ ├── query-abff188fc81caf44081e2053cb7841d1dc6c1a8965f4b862caa2f9cebcae0176.json │ │ │ ├── query-b1edd509d00577007680243589ce59570182b98a1e9059d9702d97e9eaa9cbf5.json │ │ │ ├── query-b4476bedb8e5c0f7bc21654dc62fb00fbd8a41e24efaba55be8278031d71cc59.json │ │ │ ├── query-b4ff8dabb0d5c99319fad3f2f7e620523c96b89beaf1edd97f79d9972b93c8fe.json │ │ │ ├── query-c097fa44c48e55f0e74f56577c0c1c4b3b92b2875d12c0bd1a70a1dcc4eda58e.json │ │ │ ├── query-c24119a35ed2099b886a0b1a9a41adf01d1a1f86792abf3d3a410c6cbab2ec0f.json │ │ │ ├── query-c27f2fd6b3696cb5a8ec54226608440786a6cec601783f797be3a8c515080d62.json │ │ │ ├── query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json │ │ │ ├── query-c5a45e39543468b57c2e3662735c640210c3948113dcbd1be8339f2c27506b76.json │ │ │ ├── query-c793ee8493c54ea295a62a51650d00894fdad2f2cadc5665ae1e16a605626cb2.json │ │ │ ├── query-cac90f2884c7c0eed4d2ab621016a5bc62dfbcb65539eb4a52e3306f96c0698a.json │ │ │ ├── query-cd6d7ca74442a100d9caf170ac43118795226f50b8392069b47abd4f7564c135.json │ │ │ ├── query-d41acd2bd3c805f9787c0d468a25ce62bfa8b268131c19b83fd76acb59a8c9ea.json │ │ │ ├── query-d7a11078522c029b71a75f4a45abc941536d3ce08d8ee0fcbde3eacf6360b7d5.json │ │ │ ├── query-d9f7205a1a749c23c928ef2861783446bd99a7b7939929dee1f8a409bb99ab04.json │ │ │ ├── query-db1b29e1ea843ee4024c914820978a558f0ac4cc65da76645ccff4748240e565.json │ │ │ ├── query-db39f7ab7391c1289299e7f8aa7e1f642874eed0179e91a9558f9df534db797c.json │ │ │ ├── query-dc5d0ad507cbd962235c9e85c3e43f34c7c38eb2e08ab7899073010a6e77b37d.json │ │ │ ├── query-dc88d70bb25b6437580480c346ed29fb90115e3b83fa36d8966b62f02990b9c7.json │ │ │ ├── query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json │ │ │ ├── query-df2f35912a8055dff6cb24c83ea67fc49b432f457961fa584c6a13389bfdcea5.json │ │ │ ├── query-df66eae37a24c07c2ae0a521c802e3828ac153e6c087edcf2ba4dbe621dc79d3.json │ │ │ ├── query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json │ │ │ ├── query-ee06dfd8dc7fc2ffc239db9635a3a5cac2e603992392a632bff7d450c6bca061.json │ │ │ ├── query-efce74898a8e81dafc3933231e8ac3c07be392e1c073e62c621138c00d0ed30d.json │ │ │ ├── query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json │ │ │ ├── query-f584dbe0f2f2a4f1e7dcf5b8f675eb2a6d954bb3f148ac0fece10652f05fb49b.json │ │ │ ├── query-f9e8640c28fae8aebf3d8b0d3984804fdb3f197c8cc2d5750fd267c82e3e68a1.json │ │ │ ├── query-faae305f6ac9dc7d04d21c76531cde3912647430195267ffa5b99bb9a7df1feb.json │ │ │ ├── query-fb1ab168509b38eccf3064e2a90690a3fdef67a98fee7e5943689e61818d34f0.json │ │ │ └── query-fc90f4dd7a408d6129aff95538de22c3a1ca018bc7837e3dc1c5aa0007844887.json │ │ ├── Cargo.toml │ │ ├── migrations/ │ │ │ ├── 20250617183714_init.sql │ │ │ ├── 20250620212427_execution_processes.sql │ │ │ ├── 20250620214100_remove_stdout_stderr_from_task_attempts.sql │ │ │ ├── 20250621120000_relate_activities_to_execution_processes.sql │ │ │ ├── 20250623120000_executor_sessions.sql │ │ │ ├── 20250623130000_add_executor_type_to_execution_processes.sql │ │ │ ├── 20250625000000_add_dev_script_to_projects.sql │ │ │ ├── 20250701000000_add_branch_to_task_attempts.sql │ │ │ ├── 20250701000001_add_pr_tracking_to_task_attempts.sql │ │ │ ├── 20250701120000_add_assistant_message_to_executor_sessions.sql │ │ │ ├── 20250708000000_add_base_branch_to_task_attempts.sql │ │ │ ├── 20250709000000_add_worktree_deleted_flag.sql │ │ │ ├── 20250710000000_add_setup_completion.sql │ │ │ ├── 20250715154859_add_task_templates.sql │ │ │ ├── 20250716143725_add_default_templates.sql │ │ │ ├── 20250716161432_update_executor_names_to_kebab_case.sql │ │ │ ├── 20250716170000_add_parent_task_to_tasks.sql │ │ │ ├── 20250717000000_drop_task_attempt_activities.sql │ │ │ ├── 20250719000000_add_cleanup_script_to_projects.sql │ │ │ ├── 20250720000000_add_cleanupscript_to_process_type_constraint.sql │ │ │ ├── 20250726182144_update_worktree_path_to_container_ref.sql │ │ │ ├── 20250726210910_make_branch_optional.sql │ │ │ ├── 20250727124142_remove_command_from_execution_process.sql │ │ │ ├── 20250727150349_remove_working_directory.sql │ │ │ ├── 20250729162941_create_execution_process_logs.sql │ │ │ ├── 20250729165913_remove_stdout_and_stderr_from_execution_processes.sql │ │ │ ├── 20250730000000_add_executor_action_to_execution_processes.sql │ │ │ ├── 20250730000001_rename_process_type_to_run_reason.sql │ │ │ ├── 20250730124500_add_execution_process_task_attempt_index.sql │ │ │ ├── 20250805112332_add_executor_action_type_to_task_attempts.sql │ │ │ ├── 20250805122100_fix_executor_action_type_virtual_column.sql │ │ │ ├── 20250811000000_add_copy_files_to_projects.sql │ │ │ ├── 20250813000001_rename_base_coding_agent_to_profile.sql │ │ │ ├── 20250815100344_migrate_old_executor_actions.sql │ │ │ ├── 20250818150000_refactor_images_to_junction_tables.sql │ │ │ ├── 20250819000000_move_merge_commit_to_merges_table.sql │ │ │ ├── 20250902120000_add_masked_by_restore_to_execution_processes.sql │ │ │ ├── 20250902184501_rename-profile-to-executor.sql │ │ │ ├── 20250903091032_executors_to_screaming_snake.sql │ │ │ ├── 20250905090000_add_after_head_commit_to_execution_processes.sql │ │ │ ├── 20250906120000_add_follow_up_drafts.sql │ │ │ ├── 20250910120000_add_before_head_commit_to_execution_processes.sql │ │ │ ├── 20250917123000_optimize_selects_and_cleanup_indexes.sql │ │ │ ├── 20250921222241_unify_drafts_tables.sql │ │ │ ├── 20250923000000_make_branch_non_null.sql │ │ │ ├── 20251020120000_convert_templates_to_tags.sql │ │ │ ├── 20251101090000_drop_execution_process_logs_pk.sql │ │ │ ├── 20251114000000_create_shared_tasks.sql │ │ │ ├── 20251120000001_refactor_to_scratch.sql │ │ │ ├── 20251129155145_drop_drafts_table.sql │ │ │ ├── 20251202000000_migrate_to_electric.sql │ │ │ ├── 20251206000000_add_parallel_setup_script_to_projects.sql │ │ │ ├── 20251209000000_add_project_repositories.sql │ │ │ ├── 20251215145026_drop_worktree_deleted.sql │ │ │ ├── 20251216000000_add_dev_script_working_dir_to_projects.sql │ │ │ ├── 20251216142123_refactor_task_attempts_to_workspaces_sessions.sql │ │ │ ├── 20251219000000_add_agent_working_dir_to_projects.sql │ │ │ ├── 20251219164205_add_missing_indexes_for_slow_queries.sql │ │ │ ├── 20251220134608_fix_session_executor_format.sql │ │ │ ├── 20251221000000_add_workspace_flags.sql │ │ │ ├── 20260107000000_move_scripts_to_repos.sql │ │ │ ├── 20260107115155_add_seen_to_coding_agent_turns.sql │ │ │ ├── 20260112160045_add_composite_indexes_for_performance.sql │ │ │ ├── 20260113144821_remove_shared_tasks.sql │ │ │ ├── 20260122000000_add_default_target_branch_to_repos.sql │ │ │ ├── 20260123125956_add_agent_message_id.sql │ │ │ ├── 20260126000000_add_agent_working_dir_to_repos.sql │ │ │ ├── 20260128000000_add_migration_state.sql │ │ │ ├── 20260203000000_add_archive_script_to_repos.sql │ │ │ ├── 20260217120312_remove_task_fk_from_workspaces.sql │ │ │ ├── 20260220000000_optimize_query_planner_after_latest_process_query_update.sql │ │ │ ├── 20260302113031_add_worktree_deleted_to_workspaces.sql │ │ │ ├── 20260304153000_move_agent_working_dir_to_sessions.sql │ │ │ ├── 20260314000000_add_name_to_sessions.sql │ │ │ └── 20260317120000_cleanup_attachment_schema.sql │ │ └── src/ │ │ ├── lib.rs │ │ └── models/ │ │ ├── coding_agent_turn.rs │ │ ├── execution_process.rs │ │ ├── execution_process_logs.rs │ │ ├── execution_process_repo_state.rs │ │ ├── file.rs │ │ ├── merge.rs │ │ ├── migration_state.rs │ │ ├── mod.rs │ │ ├── project.rs │ │ ├── repo.rs │ │ ├── requests.rs │ │ ├── scratch.rs │ │ ├── session.rs │ │ ├── tag.rs │ │ ├── task.rs │ │ ├── workspace.rs │ │ └── workspace_repo.rs │ ├── deployment/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── executors/ │ │ ├── Cargo.toml │ │ ├── default_mcp.json │ │ ├── default_profiles.json │ │ └── src/ │ │ ├── actions/ │ │ │ ├── coding_agent_follow_up.rs │ │ │ ├── coding_agent_initial.rs │ │ │ ├── mod.rs │ │ │ ├── review.rs │ │ │ └── script.rs │ │ ├── approvals.rs │ │ ├── command.rs │ │ ├── env.rs │ │ ├── executor_discovery.rs │ │ ├── executors/ │ │ │ ├── acp/ │ │ │ │ ├── client.rs │ │ │ │ ├── harness.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── normalize_logs.rs │ │ │ │ └── session.rs │ │ │ ├── amp.rs │ │ │ ├── claude/ │ │ │ │ ├── client.rs │ │ │ │ ├── protocol.rs │ │ │ │ ├── slash_commands.rs │ │ │ │ └── types.rs │ │ │ ├── claude.rs │ │ │ ├── codex/ │ │ │ │ ├── client.rs │ │ │ │ ├── init_prompt.md │ │ │ │ ├── jsonrpc.rs │ │ │ │ ├── normalize_logs.rs │ │ │ │ ├── review.rs │ │ │ │ └── slash_commands.rs │ │ │ ├── codex.rs │ │ │ ├── copilot.rs │ │ │ ├── cursor/ │ │ │ │ └── mcp.rs │ │ │ ├── cursor.rs │ │ │ ├── droid/ │ │ │ │ └── normalize_logs.rs │ │ │ ├── droid.rs │ │ │ ├── gemini.rs │ │ │ ├── mod.rs │ │ │ ├── opencode/ │ │ │ │ ├── models.rs │ │ │ │ ├── normalize_logs.rs │ │ │ │ ├── sdk.rs │ │ │ │ ├── slash_commands.rs │ │ │ │ └── types.rs │ │ │ ├── opencode.rs │ │ │ ├── qa_mock.rs │ │ │ ├── qwen.rs │ │ │ └── utils.rs │ │ ├── lib.rs │ │ ├── logs/ │ │ │ ├── mod.rs │ │ │ ├── plain_text_processor.rs │ │ │ ├── stderr_processor.rs │ │ │ └── utils/ │ │ │ ├── entry_index.rs │ │ │ ├── mod.rs │ │ │ ├── patch.rs │ │ │ └── shell_command_parsing.rs │ │ ├── mcp_config.rs │ │ ├── model_selector.rs │ │ ├── profile.rs │ │ └── stdout_dup.rs │ ├── git/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── cli.rs │ │ │ ├── lib.rs │ │ │ └── validation.rs │ │ └── tests/ │ │ ├── git_ops_safety.rs │ │ └── git_workflow.rs │ ├── git-host/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── azure/ │ │ │ ├── cli.rs │ │ │ └── mod.rs │ │ ├── detection.rs │ │ ├── github/ │ │ │ ├── cli.rs │ │ │ └── mod.rs │ │ ├── lib.rs │ │ └── types.rs │ ├── local-deployment/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── src/ │ │ ├── command.rs │ │ ├── container.rs │ │ ├── copy.rs │ │ ├── lib.rs │ │ └── pty.rs │ ├── mcp/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── bin/ │ │ │ └── vibe_kanban_mcp.rs │ │ ├── lib.rs │ │ └── task_server/ │ │ ├── handler.rs │ │ ├── mod.rs │ │ └── tools/ │ │ ├── context.rs │ │ ├── issue_assignees.rs │ │ ├── issue_relationships.rs │ │ ├── issue_tags.rs │ │ ├── mod.rs │ │ ├── organizations.rs │ │ ├── remote_issues.rs │ │ ├── remote_projects.rs │ │ ├── repos.rs │ │ ├── sessions.rs │ │ ├── task_attempts.rs │ │ └── workspaces.rs │ ├── relay-control/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ └── signing.rs │ ├── relay-tunnel/ │ │ ├── .sqlx/ │ │ │ ├── query-13462773c343a0812783b914d7a09b6e7148d20be4c2a5c92fa5860e1bc5bd36.json │ │ │ ├── query-25045af6947be74c0a4f1784670904bd488d1cbafe997a2d8abef620d2e5497f.json │ │ │ ├── query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json │ │ │ ├── query-2b81f9454b626be80c21761b0fd1e7a83b71bb53a4ababf212d4fb13636119ae.json │ │ │ ├── query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json │ │ │ ├── query-5ba9186639e75711df9218209ad88f91f54f9643ecf5f53af1e7bfc583727a7c.json │ │ │ ├── query-6aef9ee49b4bc1d9a23c0322e7733bee239e31380cb4cf8274cb60427b492299.json │ │ │ ├── query-6d64fbb63cd05c4cd9bcc3d07cbc26b165f0d0ffbc4df75391c6b205fe0abd78.json │ │ │ ├── query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json │ │ │ ├── query-7b010dece6caaeb04e8f033c869982b97f20c60903a92f4d45634f990ffbfce3.json │ │ │ ├── query-80dfc0d5bbc6db412ea0fda24a5184c3ce20064826779571cab1d9e32459c4cb.json │ │ │ ├── query-8b865d8fca4a3d7a8f7edf6671aed582164f93f973448143883d6d2fb461caf6.json │ │ │ ├── query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json │ │ │ ├── query-9c2e6a8fc112e4e2980e4dc3f1dae1ea7da376119b0f06aafbc74c7a471f17ad.json │ │ │ ├── query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json │ │ │ ├── query-e84a068d6a2ad1458cf6f45c2f2dde8511355f29677cebfd15783fccd095a131.json │ │ │ ├── query-f31c0531cb5c099bc0c6b193852d3e3d0be6cfe1104dd792651d9c5434483ef0.json │ │ │ └── query-f6d727f8d7baa7b92464ccccccd113fbe7c69a69d91dc6f54c6052eaa65ce868.json │ │ ├── Cargo.toml │ │ ├── Dockerfile │ │ └── src/ │ │ ├── bin/ │ │ │ └── relay_server.rs │ │ ├── client.rs │ │ ├── lib.rs │ │ ├── server.rs │ │ ├── server_bin/ │ │ │ ├── auth.rs │ │ │ ├── config.rs │ │ │ ├── db/ │ │ │ │ ├── auth_sessions.rs │ │ │ │ ├── hosts.rs │ │ │ │ ├── identity_errors.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── relay_auth_codes.rs │ │ │ │ ├── relay_browser_sessions.rs │ │ │ │ └── users.rs │ │ │ ├── mod.rs │ │ │ ├── relay_registry.rs │ │ │ ├── routes/ │ │ │ │ ├── auth_code.rs │ │ │ │ ├── connect.rs │ │ │ │ ├── mod.rs │ │ │ │ └── path_routes.rs │ │ │ └── state.rs │ │ ├── tls.rs │ │ └── ws_io.rs │ ├── remote/ │ │ ├── .sqlx/ │ │ │ ├── query-00f50fdb65f4126b197b523f6fc1870571c4c121c32e0c3393f6770fc3608e95.json │ │ │ ├── query-00f5a09dfd00355a8657007f6d7b3a2a98547db4acccd485cec20d8fd29815ad.json │ │ │ ├── query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json │ │ │ ├── query-082aaf51a023c8ccb44002ce48287acd8ef90b0f4c8338447c6e5370ca93390b.json │ │ │ ├── query-08fa6f887e954e3b6921f84bbd412b4c3fc5dc1df0b9a5ea3fa4a4b07a86bb55.json │ │ │ ├── query-0a57abb390861f8e9ce1da411934bef0a1a4edcea151cbf78fdf4cb510a0d450.json │ │ │ ├── query-0c5dfb11325fb2f0ea279c9406d593376bece575358831870012d125fd053be3.json │ │ │ ├── query-0df35d620c891a94f62e7e3f7afb60819783f961be1dd36cabb478c5e3ad23c0.json │ │ │ ├── query-10428c897273798508759a89323d4fb181081eb5ffea40ef41a4d5437b7b6849.json │ │ │ ├── query-11eede7c3a324ffa6266ee5c3fe3fdb2bd3b9e894fcabeece1e8d2201d18dcc6.json │ │ │ ├── query-12eb8caf8044a790e7390882bc07d8c737581e0926d473b2e0a9eaccdd0a8674.json │ │ │ ├── query-1565680821f93069b2b5c109a7d1ba10889ca9b98c848895de6ef2c3ef4dffa0.json │ │ │ ├── query-16abe1e4d69bf90ed05d8651b688e3be23a74d8dd3957a976c7b757660d5b169.json │ │ │ ├── query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json │ │ │ ├── query-185b7fc8f6f22b7c29950a490d46bb16c4fec50cf6e8dc988f3a2c942be909c0.json │ │ │ ├── query-187b173294e46a013a48040ca4375b65df44215d8883cae88123f762880507e9.json │ │ │ ├── query-18ae849cdeff678538d5bd6782e16780da9db40e4d892a75d7d244f247db5c04.json │ │ │ ├── query-18f2fc4074de23b6b2a0c2c70403d6a1eaa57e1fda5063d9f3a292e8aab61ede.json │ │ │ ├── query-198df1da04fb3ffee213718de87fa49d5032545d55d45a7cb0c62dcc60db5f78.json │ │ │ ├── query-1b2c4d4205244ed0fa457ebc3b42147c9446a7efb5e205cd85aa780f99824b88.json │ │ │ ├── query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json │ │ │ ├── query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json │ │ │ ├── query-1c57e525a7060361832601f158977fffec60c36534ec8eb9affbdf648c280334.json │ │ │ ├── query-1d612faf67c945cfe22cfd7ab6b6d360fbce8dceb7b64c4d17b4df108434c822.json │ │ │ ├── query-1d6f13e86897b0885ac3caa36bd56a8685e137a5e22545776b16a5814f225211.json │ │ │ ├── query-1e2aef04b2d7b1ece13c96ac1dd7718d59c6e8f3dbf0606789fc9f664ac33332.json │ │ │ ├── query-1ea6478b8325ce0313727f756715c988d0c03ccb74a87e67325c73c03a5dcc33.json │ │ │ ├── query-209f1b560de8e99de312394860b42251b0272fc7f8f57ea50c9a16fb026b5ae4.json │ │ │ ├── query-2129f33c4fdf5d1bf52cfac30238e36ffacaab20fb2cf4111fa70ba4e5aa1bca.json │ │ │ ├── query-253ed3e27e9c1798ecadf943e621bf2993ffdf2267e2582679656ccde7a33c67.json │ │ │ ├── query-28f6198cfd9c7a01e437a72e5cb3e076f5183a457cb6389cb56d047c1dcce439.json │ │ │ ├── query-2aa7a0c029cf5fde56e413c13af502a0656051e41e1d036805cc427514c37337.json │ │ │ ├── query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json │ │ │ ├── query-2b3d84d8febea88a7957efbfd0ca68ee279bc57c6a60afecf9073f46445163a2.json │ │ │ ├── query-2d85b3d08704ce8475a15a7c8d10a5c1afd97f8ae8e126d26844735f7449fb19.json │ │ │ ├── query-2db4c808f8d1f22c6209027007ebeb2bd58580758abf8996797b5338d793f741.json │ │ │ ├── query-2f3898ec50ee1386f87786c605069aac78d5177feaabd719b60e54f94f5f535e.json │ │ │ ├── query-301e398b03c6e376d3ebd8dc9373f5724ae535e773588ab75baa29468a495ef4.json │ │ │ ├── query-31c99a55082ff59e212e1fe5425b695030dcf4cfe029ffbb1b56813106a563dc.json │ │ │ ├── query-32388086083c01d21b0d4d052519a08002b82751e45aa59b3ac628cc96be2723.json │ │ │ ├── query-3239a6b54374bfba7c1ee16f151333563e21af8994d0431acf029e6a2ca08bfd.json │ │ │ ├── query-3690a7ea5e1250ceca638bad754a77df36031d8ca132402cc9256f71a57fa476.json │ │ │ ├── query-37af75dde5f977838d59b57729e9a238d2d2def278d376adc1d4c1d038a918cf.json │ │ │ ├── query-389b412ed9b76973a5b1546a24167e0b752467405f024de73101b6c12e1e05f1.json │ │ │ ├── query-3e682d961f272a5c1ce20366008889156928c87babc1704d3277ff9a1812193c.json │ │ │ ├── query-3ef67cb768d55e4aa8d551401be7daa6c8a9c76a4218ce776d87db6e6d1c890c.json │ │ │ ├── query-40c9618c70aae933513bd931a3baace6830d78daacfcbd7af69e4f76a234d01c.json │ │ │ ├── query-421ed23a0bff456c54a14068ceed214fa64d0c50e432fcfe40c222991341bf68.json │ │ │ ├── query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json │ │ │ ├── query-426eb8216286273dd0066a15ce4508d9fed04d2feccfff81abb4813ebfea9778.json │ │ │ ├── query-4274624ba6445ad370380230898232b12365b2336e235b045b1ad25c958c902d.json │ │ │ ├── query-4411b08341b6b5516505ef4d218e0e46cebe76085e49f4cb88fcdc40816d1228.json │ │ │ ├── query-4447c24a9150eb78d81edc26a441a50ee50b8523c92bfe3ccc82b09518608204.json │ │ │ ├── query-45010d9fa4bde72535c3f23f06b7aa9dbf01cf287159476852e5f87496d94ea4.json │ │ │ ├── query-4508b7a46677e8da7a397979a22c1a3e1160c7407b94d7baa84d6a3cdc5667c5.json │ │ │ ├── query-4593ea3d9f66cb2618bf444ddbab1e8f2b790471f32aaf192e93f9226fc042bc.json │ │ │ ├── query-471944787bb9b58a1b30628f28ab8088f60bf3390bfaddbae993e87df89b8844.json │ │ │ ├── query-47c186223fb7e3c66fff44c1029cf04fb872064a1d8c14bf7d76a841cfe904a6.json │ │ │ ├── query-4815234c108e45d450f433e5daca76218abdb441b9475ba916a39ab9e1341030.json │ │ │ ├── query-4b27e9774d71a851edc8c042e682037a35bd4cdffe22f3a13e1730f0d6712485.json │ │ │ ├── query-4cc8dc5f57a8398ef28942eab072784543333eac379d78c5843ca0c2203b69f5.json │ │ │ ├── query-4d963a12190ee1db657446ef451c5364f8f91153f7f1bb4e5abfd3f3ddbe0461.json │ │ │ ├── query-4decb0554367c10f06a45f14291e5ba2a3e16aaf63bf1c34c2e8bc0c249fe4dd.json │ │ │ ├── query-4e74faa43c070a492467104f59f81a8cb7e304593dd8cc12523b2c9052a48275.json │ │ │ ├── query-4f80d17d6ca14600ec33d3660b8aa2efb385baf0384b6e666c3d25f0dad3c902.json │ │ │ ├── query-4fc440b2735dfe8561c3f75440d8eaab32d1c31c994e17f319f52045bf96714f.json │ │ │ ├── query-51fe714966b7474d7f96cda8411b353e51efd935c929b689a8c33872d6a887b0.json │ │ │ ├── query-547e9a424c4baa6d0a39299996fc8ee6abf88c2b6f687a17ec8216059de49596.json │ │ │ ├── query-55f054b37280bfa43dbea79edd61ba969bacf776c0be43b608b5b0ca3f68c1fe.json │ │ │ ├── query-56b1a366106974ec86702175bc3b4cad61f7437599082e142262169647df324d.json │ │ │ ├── query-56d467122fa8b6599dc8821f65c2b191f378c9a76d3707d63d8cee1ef31fe4ba.json │ │ │ ├── query-56d8fd993d1926824c84fff5b5a7f918f06e301ba4938075305eb575d310e891.json │ │ │ ├── query-56efc697008a751a659452d95248636ce60c7f13fb2a3ef3f5440a7c795b13eb.json │ │ │ ├── query-574f50459071d9a400bad0c7623ab1618c6ae90b4a60adb8cb4a75628cb22c1c.json │ │ │ ├── query-577b1dc54aeefe702c74a56776544a391429b561b76d36d59673e410d5d78576.json │ │ │ ├── query-58d7e8202ef0fb891303c761ae83a803459ffdda3c2a43ca3d6f74c0e3ecb34d.json │ │ │ ├── query-5a652dd2a3d8bcbc8824584f8a1d9ccbb1fa56f54575b6c9dcd855a26de1edc5.json │ │ │ ├── query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json │ │ │ ├── query-5ce478f8221034468e5ea9ec66051e724d7054f8c62106795bccf9fd5366696d.json │ │ │ ├── query-5dae00eb6e3bef4d8ded1db51ad1252f6df335355b877f0dd64075f74c0018b8.json │ │ │ ├── query-5f8a332903cbf55aca62cf642bfca4e1815e2b168889f3a5983cb859c77a75b6.json │ │ │ ├── query-61245f2cee584d03acf4fd65dec00d22076134f726e5a5f4f13d1f4fc2060974.json │ │ │ ├── query-6205d4d925ce5c7ab8a91e109c807b458a668304ed6262c5afab4b85a227d119.json │ │ │ ├── query-622e613f8a71f6dd4d110df061bff6ab4e46636ab60dd85dbccd9181d004de33.json │ │ │ ├── query-623c1d7933109030c4dbbf84d6028d1a7c94394906d1300c257c2e657925eb25.json │ │ │ ├── query-62a28e66786692c5525ac4266bd3120d75ada4b85ed14f6815231c8691604e2f.json │ │ │ ├── query-633bc2ca535b8b0078e81e188c734426421fe426dfb90697d025556cc8cb723f.json │ │ │ ├── query-63ad252e1cd34aca9f819e457d0184c8df21cb4d2b1606ef84c3bdf5fc4457b0.json │ │ │ ├── query-6412e3c9c929c588d924c1f899891f5d47f92d48b19f93823fb5a795d44a736a.json │ │ │ ├── query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json │ │ │ ├── query-667708775c67d5b3ee9a55730434f37f9ae7a49ba89301999fbb1e20aef9bb42.json │ │ │ ├── query-66eefd452f6ccbd5bc757154a1da211c8134075b7c9f42dacc4fecaedd1c8737.json │ │ │ ├── query-68422b179dc361337c65a6bd1aa455a961708b97a673d84f7af64cd252cbfdf3.json │ │ │ ├── query-68494b64181d1ca4293962abfcf0af30e5b4d6947dd4e9509bfc21d8fe4b93d5.json │ │ │ ├── query-690c16c206895598016a784884f1a764f4a921232df68cc046495ff4f39827ec.json │ │ │ ├── query-6a1bfdce77c93b841ff0c3b533a71e6d9c9d333659de1b12ffbe462ae0123bd5.json │ │ │ ├── query-6be91bb87b8d2b28f600bf4f59224281d676281278f6c6bf266a1aa3a91d44fd.json │ │ │ ├── query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json │ │ │ ├── query-6fae9f0d59fa5fb6b03ba068d1b50e82aa1b91fa2abe782bdbddd4ccbbd7971c.json │ │ │ ├── query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json │ │ │ ├── query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json │ │ │ ├── query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json │ │ │ ├── query-79dc2aa6cb26c21530ac05b84ec58aff9b042724bda846aadd9bf1b1a3a53791.json │ │ │ ├── query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json │ │ │ ├── query-7a96ad78e02ebdb1f6d29d941a3c393b128a7165123b63c455df2c2581995e35.json │ │ │ ├── query-7c54f2956d1c6f0912da45e40590e2bfbb1e5c24c374c2d68ca5b692c87cf26f.json │ │ │ ├── query-7d628ce544ed41baf2d0cdc0c95f35ac324474564b8cbb6735c9a7fc6aff75fa.json │ │ │ ├── query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json │ │ │ ├── query-7f100e4420b2b8c086eac892d13f0ed114a5667b9c26fe7d99dcff1f4b3b1a9f.json │ │ │ ├── query-7fb263a325db6e402761a9d0643561b134deda610f7f163d38c20625a4fdd048.json │ │ │ ├── query-80c3a6879ba2142e78a397340501ac402808707724c58a67db7c7bb9040a7cb9.json │ │ │ ├── query-8123b99c8d0df1c3a39ae0b2e02b8f95e438dcaa7f85e4ad37a069d962ae2e39.json │ │ │ ├── query-823f54d7b4eb060b1c5eb4e45143e668286ae6716705e55bb4f4f0f89a5b4117.json │ │ │ ├── query-82dcc3cd88256066ad91785afe686ec03090ea549029ba2c701cdfa2c1501f0d.json │ │ │ ├── query-830e7650bdeccf581f260646182b3b5af903927702022ba1a4293d9d8627f727.json │ │ │ ├── query-83edd4a9b106ad4dfda19ed983d27aee591e50a5a5f4774dbe6d68265da0c6de.json │ │ │ ├── query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json │ │ │ ├── query-864ea9a40e219fdf04230331e225d699677200d5ccd3d4e12842060a657bd8ea.json │ │ │ ├── query-86d32fea56d89959413ec714af2decbfd0b58a60ab4833cb300f15eac9061ff7.json │ │ │ ├── query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json │ │ │ ├── query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json │ │ │ ├── query-878adca3c3dc2383f7dd86e19026f9aa18d6b2ac20a46a97630a43f9c5ee99eb.json │ │ │ ├── query-883490bd163237d721488136ba8efbe81f42357213817ce1efe61e6036184b3e.json │ │ │ ├── query-88ac4f15091a552f40c752a56d2894bb4f46c5eaeff9eec813e2d3c032de3e82.json │ │ │ ├── query-8b2002931058f7e268604b9ad4f2a12ec6388fa66e20f8d3cc0f70c10e3d43ea.json │ │ │ ├── query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json │ │ │ ├── query-8e32d5bf86d112e2f4a16f622bd95c8f728946f01e1a994a9c66b0fac6e3ae52.json │ │ │ ├── query-8e96696a873dbbd175f1d73eb03773beab823476f6d5712c02633dbc6efa0159.json │ │ │ ├── query-8e9d6c188fe09693d027d408b15792cbbebc72d2dd5bd4e2de12ef533a073f75.json │ │ │ ├── query-8fc5f7e1920e9d43034aeaacb0a00739e0ee3cd00d06a692beac0f0fb2324ac8.json │ │ │ ├── query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json │ │ │ ├── query-91776c42e46c2b2a3909baccf21dd83e2e1c88e592e94699a7a286fd396f2812.json │ │ │ ├── query-92768b10d8dcb0593c1c5558d847aae11301163ffc053e89f08cae48a94753a0.json │ │ │ ├── query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json │ │ │ ├── query-95427f2ba8293a8aa51366aad80129a3cfdcd1b3ec4dc8298d3aa7d0c5419191.json │ │ │ ├── query-9889a5e2b2b849138e5af7bb649c9833cfa4fbc45c3bed269d25a8ada30634e4.json │ │ │ ├── query-9b2762d25c773099f99e6ae65ccefc16ac367d725df8ebb7983420aa0fce4149.json │ │ │ ├── query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json │ │ │ ├── query-a1431ca78db627fef0eca6f573b34d65510e9333765126cbd80c943046dfaea8.json │ │ │ ├── query-a4f5f53d0b9882e4e8147be7b618cb3dd18d0b5c74f4fd4faf13b4be6c6704ad.json │ │ │ ├── query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json │ │ │ ├── query-a5d10a37114cf01163a023d70212d17b963a27528089dca9d8fe8503335ad14b.json │ │ │ ├── query-a5d1aeb3ce62a3f286a2a4bddc38c3f5caf2eb556236b561b68483b17dc24cfd.json │ │ │ ├── query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json │ │ │ ├── query-aa31348f22b24c16e1d1365c2508ad7b6c155ef2a50cabd80b59e297001dd93a.json │ │ │ ├── query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json │ │ │ ├── query-b33110a056cf1ed2bb527aa975f8099d52ac0c9482cdf695a980fad0223ea136.json │ │ │ ├── query-b5a2ccf794217a408e9ffb663183af1ba203d6d2274e9562a9e3aa938ea6d71b.json │ │ │ ├── query-b97175fb9a4f5a7379119da3760be7efc1ba2bf95bd5d3e6725f4f98aa7d955a.json │ │ │ ├── query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json │ │ │ ├── query-baa0922dd5a1e99794f480c483124402f5e1cd014e87919a72d468cd9762ec49.json │ │ │ ├── query-bc0e36b956903c2ace672e6c52516598ec5f2b0288dcb935ef4d1bc694dacf0d.json │ │ │ ├── query-bd632f11a197d6a17fcdf3e757283a64d281a931aaacd1ed6e4b73f18f1b6a2f.json │ │ │ ├── query-bdbdee30d4f94a94f3d449aaea132512d8334a6a2636f898facd4e916a683f5e.json │ │ │ ├── query-bee3a7f9d08e5634eb32e750701822a8f4efa01d301c8227e67783b435ee90cc.json │ │ │ ├── query-bf4a22fed2026657255fd032fcbc1ee14c27e48df5fa21fcc6202863520dbf98.json │ │ │ ├── query-bfc7aa46dc9d6d70c2fed471996ddcbf4d723f0e41aa17f7d2be0cf277350410.json │ │ │ ├── query-c27e4a5df0dbc872c6ae2c35abf0868b70ba141486e15a70e61c18e97f9e9213.json │ │ │ ├── query-c30d54026d89b9cf9c938f5aff5cf09ca12af2ab456094aac9a417473645b7e4.json │ │ │ ├── query-c392eefb0fa2803536184053eaa22d63b6af8119f60419b8117f332cf48912de.json │ │ │ ├── query-c48b9162c22c3d0ad7d2e2f34ecca353b807876aebf3540e5a024669ac2bb613.json │ │ │ ├── query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json │ │ │ ├── query-c6cccc00461c95d86edc5a1f66b8228fb438985f6b78f9d83663ecb11d59675f.json │ │ │ ├── query-c850c165c1041f6b9ef852f8bb6c36f0558bd305000151834a884d7629521d28.json │ │ │ ├── query-c91d02654edf56d33a7eb9d33d3423ac2b0a59bdd89eb7d8aeadbabb2af72314.json │ │ │ ├── query-c9499f4408f22989b6f55f1641e7e1a82b2f32e079c6b3ee0d9f6a47a15a2522.json │ │ │ ├── query-ca680e4e2a221ccaf578639b96730fa0d0fd4451d956f9dfa46670f5980c29a8.json │ │ │ ├── query-cbeee2168e74df2896cbb063187cd1acc8a5429bfaec80f32764676dafd2cd1e.json │ │ │ ├── query-cc7d93b529cfbddc9921ae33533572062f2e072f5be0fcb26032cbfec2fb3118.json │ │ │ ├── query-cccab845031104e7a06d411cfbbbf7465f73051b30ec06d21a4c687ec175a58c.json │ │ │ ├── query-ce7908cdeecd4b4b94c92256bd800c165567ebe5644cfd702a9e4c0bb24091d4.json │ │ │ ├── query-d0e511b622ffba9354c3be61112b392f7c22eb9facc97730d5b4ee62c248fff8.json │ │ │ ├── query-d1a4753755833d5100bcf4b61449f58fa83f7ee511ce0b15c7dc00c2d8c01560.json │ │ │ ├── query-d2275416ba3ddb1bbaf929787b5df4c736084582ddebdbe9f4a4aa6853727484.json │ │ │ ├── query-d4931e5f81b8a68f983d3e43b319a0f145339b7d8f878c3c1a765f41f3f4697c.json │ │ │ ├── query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json │ │ │ ├── query-da660b40d95d5fa5e9176b0b5859bb594e83fc21664f062f29ed148969b17c0b.json │ │ │ ├── query-db645795e781123885506fe4f8e4f1a77a82d0dde22fd876f9b84dd04063db65.json │ │ │ ├── query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json │ │ │ ├── query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json │ │ │ ├── query-ddb471fb54ccc7b6438a15f8de8c9eba7e32eb51866b7f2871df2300bfe7cf40.json │ │ │ ├── query-df27dcabe19b0b1433865256b090f84474986985ec0d204ab17becd6d3568d0a.json │ │ │ ├── query-dfad52e56108583960a73a9b89cb91e4da97e212313adc2db73a64cc8c473a87.json │ │ │ ├── query-dfbf03c5f333dfc7f531f415f4816603d080a544699705329dfe2e93e33c2886.json │ │ │ ├── query-e0a011a3d29e5ae50ff06a264c39655e32be70ba76939a82184bf0dc5e8d6968.json │ │ │ ├── query-e161b18662654bba364a273f67486b9366d5a972fc4968b03aa4c9067b92389d.json │ │ │ ├── query-e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7.json │ │ │ ├── query-e2894ddc831401000e89318423f70f221248b494ff81c1966e59c32e70a87502.json │ │ │ ├── query-e2bf31db16ca8adc105f79f00c26d6af8b542f1f1e57e947ae39197d94dd3fed.json │ │ │ ├── query-e509e51e9b1fe5e989713ab048e2641e6d1450f5506502b5a261e93dbb284226.json │ │ │ ├── query-e553f31a70abb9d7e39755633f67f2b9c21ab6552986181acc10a1523852655c.json │ │ │ ├── query-ea41e984b0e7c1c952cb265659a443de1967c2d024be80ae1d9878e27b474986.json │ │ │ ├── query-ec42318654455b31681de774e8d1e07efae222e1d5c97146a4e0054f74c0b2cc.json │ │ │ ├── query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json │ │ │ ├── query-f00d3b1e7ce2a7fe5e8e3132e32b7ea50b0d6865f0708b6113bea68a54d857f4.json │ │ │ ├── query-f036504d4bec68969c545881e684ab0dd9fcb85285e4d541d97a7e6be1681e38.json │ │ │ ├── query-f04fc738e518f28f1f148245ae92c177289f673ef6a631d65b92bd5ee841bb52.json │ │ │ ├── query-f20260cbe7dff433f5aefa4fe14fa9bc89a6ad97d550420c768e326de6ae5ae6.json │ │ │ ├── query-f2e8e193cc183a69527708cdd65fc8f0dc9ac4d9fcf67b8ac5285d068c161e06.json │ │ │ ├── query-f360cdb953a3e2fb64123ab8351d42029b58919a0ac0e8900320fee60c5c93b2.json │ │ │ ├── query-f4015e2352122c1819ff7e7a4dff62b9387f439d80f47bf457b20663c24b861a.json │ │ │ ├── query-f403f8876022d19b330e4fc0b550e2ef8bb14a08de3530cb541ae09e1a479d45.json │ │ │ ├── query-f5eff8b44dfd3aceb4e1fc1a4b58c4e74c8fac220e9943daf4103eb9e57af051.json │ │ │ ├── query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json │ │ │ ├── query-f9491f7f61aec53b057689bc722b6f20c2646510bfcd8b38c27576769a53e750.json │ │ │ ├── query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json │ │ │ ├── query-fcffbcc41e058a6d055bec006e7287fcfb26b609107d753e372faeb7f9d92302.json │ │ │ ├── query-fe9ae1da931e14f97d432ad34fe636b4854c7f85665b90337b342663bdde68b9.json │ │ │ └── query-ffea7acda162fba35e1b1acd2c6791bc917f086bf1c34816178282f0579c1eeb.json │ │ ├── AGENTS.md │ │ ├── Cargo.toml │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── migrations/ │ │ │ ├── 20251001000000_shared_tasks_activity.sql │ │ │ ├── 20251117000000_jwt_refresh_tokens.sql │ │ │ ├── 20251120121307_oauth_handoff_tokens.sql │ │ │ ├── 20251127000000_electric_support.sql │ │ │ ├── 20251201000000_drop_unused_activity_and_columns.sql │ │ │ ├── 20251201010000_unify_task_status_enums.sql │ │ │ ├── 20251212000000_create_reviews_table.sql │ │ │ ├── 20251215000000_github_app_installations.sql │ │ │ ├── 20251216000000_add_webhook_fields_to_reviews.sql │ │ │ ├── 20251216100000_add_review_enabled_to_repos.sql │ │ │ ├── 20260112000000_remote-projects.sql │ │ │ ├── 20260114000000_electric_sync_tables.sql │ │ │ ├── 20260115000000_billing.sql │ │ │ ├── 20260204000000_issue_attachments.sql │ │ │ ├── 20260205000000_add_issue_creator.sql │ │ │ ├── 20260213000000_pending_uploads.sql │ │ │ ├── 20260216000000_remove_attachment_electric_sync.sql │ │ │ ├── 20260217000000_add_project_sort_order.sql │ │ │ ├── 20260226000000_add_encrypted_provider_tokens_to_oauth_accounts.sql │ │ │ ├── 20260226100000_relay_hosts_and_sessions.sql │ │ │ ├── 20260310000000_add_title_description_notification_types.sql │ │ │ ├── 20260311000000_notification_digest.sql │ │ │ └── 20260313000000_fix-short-id-counter.sql │ │ ├── scripts/ │ │ │ └── prepare-db.sh │ │ └── src/ │ │ ├── analytics.rs │ │ ├── app.rs │ │ ├── attachments/ │ │ │ ├── cleanup.rs │ │ │ ├── mod.rs │ │ │ └── thumbnail.rs │ │ ├── audit/ │ │ │ └── mod.rs │ │ ├── auth/ │ │ │ ├── handoff.rs │ │ │ ├── jwt.rs │ │ │ ├── middleware.rs │ │ │ ├── mod.rs │ │ │ ├── oauth_token_validator.rs │ │ │ └── provider.rs │ │ ├── azure_blob.rs │ │ ├── billing.rs │ │ ├── bin/ │ │ │ └── generate_types.rs │ │ ├── config.rs │ │ ├── db/ │ │ │ ├── attachments.rs │ │ │ ├── auth.rs │ │ │ ├── blobs.rs │ │ │ ├── digest.rs │ │ │ ├── electric_publications.rs │ │ │ ├── github_app.rs │ │ │ ├── hosts.rs │ │ │ ├── identity_errors.rs │ │ │ ├── invitations.rs │ │ │ ├── issue_assignees.rs │ │ │ ├── issue_comment_reactions.rs │ │ │ ├── issue_comments.rs │ │ │ ├── issue_followers.rs │ │ │ ├── issue_relationships.rs │ │ │ ├── issue_tags.rs │ │ │ ├── issues.rs │ │ │ ├── migration.rs │ │ │ ├── mod.rs │ │ │ ├── notifications.rs │ │ │ ├── oauth.rs │ │ │ ├── oauth_accounts.rs │ │ │ ├── organization_members.rs │ │ │ ├── organizations.rs │ │ │ ├── pending_uploads.rs │ │ │ ├── project_notification_preferences.rs │ │ │ ├── project_statuses.rs │ │ │ ├── projects.rs │ │ │ ├── pull_requests.rs │ │ │ ├── reviews.rs │ │ │ ├── tags.rs │ │ │ ├── types.rs │ │ │ ├── users.rs │ │ │ └── workspaces.rs │ │ ├── digest/ │ │ │ ├── email.rs │ │ │ ├── index.mjml │ │ │ ├── mod.rs │ │ │ └── task.rs │ │ ├── github_app/ │ │ │ ├── jwt.rs │ │ │ ├── mod.rs │ │ │ ├── pr_review.rs │ │ │ ├── service.rs │ │ │ └── webhook.rs │ │ ├── lib.rs │ │ ├── mail.rs │ │ ├── main.rs │ │ ├── middleware/ │ │ │ ├── mod.rs │ │ │ └── version.rs │ │ ├── mutation_definition.rs │ │ ├── notifications.rs │ │ ├── r2.rs │ │ ├── routes/ │ │ │ ├── attachments.rs │ │ │ ├── billing.rs │ │ │ ├── electric_proxy.rs │ │ │ ├── error.rs │ │ │ ├── github_app.rs │ │ │ ├── hosts.rs │ │ │ ├── identity.rs │ │ │ ├── issue_assignees.rs │ │ │ ├── issue_comment_reactions.rs │ │ │ ├── issue_comments.rs │ │ │ ├── issue_followers.rs │ │ │ ├── issue_relationships.rs │ │ │ ├── issue_tags.rs │ │ │ ├── issues.rs │ │ │ ├── migration.rs │ │ │ ├── mod.rs │ │ │ ├── notifications.rs │ │ │ ├── oauth.rs │ │ │ ├── organization_members.rs │ │ │ ├── organizations.rs │ │ │ ├── project_statuses.rs │ │ │ ├── projects.rs │ │ │ ├── pull_requests.rs │ │ │ ├── review.rs │ │ │ ├── tags.rs │ │ │ ├── tokens.rs │ │ │ └── workspaces.rs │ │ ├── shape_definition.rs │ │ ├── shape_route.rs │ │ ├── shape_routes.rs │ │ ├── shapes.rs │ │ ├── shared_key_auth.rs │ │ └── state.rs │ ├── review/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── api.rs │ │ ├── archive.rs │ │ ├── claude_session.rs │ │ ├── config.rs │ │ ├── error.rs │ │ ├── github.rs │ │ ├── main.rs │ │ └── session_selector.rs │ ├── server/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── src/ │ │ ├── bin/ │ │ │ └── generate_types.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── middleware/ │ │ │ ├── error_logging.rs │ │ │ ├── mod.rs │ │ │ ├── model_loaders.rs │ │ │ ├── origin.rs │ │ │ └── relay_request_signature.rs │ │ ├── preview_proxy/ │ │ │ ├── bippy_bundle.js │ │ │ ├── click_to_component_script.js │ │ │ ├── devtools_script.js │ │ │ ├── eruda_init.js │ │ │ └── mod.rs │ │ ├── routes/ │ │ │ ├── approvals.rs │ │ │ ├── attachments.rs │ │ │ ├── config.rs │ │ │ ├── containers.rs │ │ │ ├── events.rs │ │ │ ├── execution_processes.rs │ │ │ ├── filesystem.rs │ │ │ ├── frontend.rs │ │ │ ├── health.rs │ │ │ ├── migration.rs │ │ │ ├── mod.rs │ │ │ ├── oauth.rs │ │ │ ├── organizations.rs │ │ │ ├── relay_auth.rs │ │ │ ├── relay_ws.rs │ │ │ ├── releases.rs │ │ │ ├── remote/ │ │ │ │ ├── issue_assignees.rs │ │ │ │ ├── issue_relationships.rs │ │ │ │ ├── issue_tags.rs │ │ │ │ ├── issues.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── project_statuses.rs │ │ │ │ ├── projects.rs │ │ │ │ ├── pull_requests.rs │ │ │ │ ├── tags.rs │ │ │ │ └── workspaces.rs │ │ │ ├── repo.rs │ │ │ ├── scratch.rs │ │ │ ├── search.rs │ │ │ ├── sessions/ │ │ │ │ ├── mod.rs │ │ │ │ ├── queue.rs │ │ │ │ └── review.rs │ │ │ ├── tags.rs │ │ │ ├── terminal.rs │ │ │ └── workspaces/ │ │ │ ├── attachments.rs │ │ │ ├── codex_setup.rs │ │ │ ├── core.rs │ │ │ ├── create.rs │ │ │ ├── cursor_setup.rs │ │ │ ├── execution.rs │ │ │ ├── gh_cli_setup.rs │ │ │ ├── git.rs │ │ │ ├── integration.rs │ │ │ ├── links.rs │ │ │ ├── mod.rs │ │ │ ├── pr.rs │ │ │ ├── repos.rs │ │ │ ├── streams.rs │ │ │ └── workspace_summary.rs │ │ ├── startup.rs │ │ └── tunnel.rs │ ├── server-info/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── services/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── lib.rs │ │ │ └── services/ │ │ │ ├── analytics.rs │ │ │ ├── approvals/ │ │ │ │ └── executor_approvals.rs │ │ │ ├── approvals.rs │ │ │ ├── auth.rs │ │ │ ├── config/ │ │ │ │ ├── editor/ │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ └── versions/ │ │ │ │ ├── mod.rs │ │ │ │ ├── v1.rs │ │ │ │ ├── v2.rs │ │ │ │ ├── v3.rs │ │ │ │ ├── v4.rs │ │ │ │ ├── v5.rs │ │ │ │ ├── v6.rs │ │ │ │ ├── v7.rs │ │ │ │ └── v8.rs │ │ │ ├── container.rs │ │ │ ├── diff_stream.rs │ │ │ ├── events/ │ │ │ │ ├── patches.rs │ │ │ │ ├── streams.rs │ │ │ │ └── types.rs │ │ │ ├── events.rs │ │ │ ├── execution_process.rs │ │ │ ├── file.rs │ │ │ ├── file_ranker.rs │ │ │ ├── file_search.rs │ │ │ ├── filesystem.rs │ │ │ ├── filesystem_watcher.rs │ │ │ ├── migration/ │ │ │ │ ├── error.rs │ │ │ │ ├── mod.rs │ │ │ │ └── types.rs │ │ │ ├── mod.rs │ │ │ ├── notification.rs │ │ │ ├── oauth_credentials.rs │ │ │ ├── pr_monitor.rs │ │ │ ├── qa_repos.rs │ │ │ ├── queued_message.rs │ │ │ ├── remote_client.rs │ │ │ ├── remote_sync.rs │ │ │ └── repo.rs │ │ └── tests/ │ │ └── filesystem_repo_discovery.rs │ ├── tauri-app/ │ │ ├── Cargo.toml │ │ ├── Info.plist │ │ ├── build.rs │ │ ├── capabilities/ │ │ │ └── default.json │ │ ├── gen/ │ │ │ └── schemas/ │ │ │ ├── acl-manifests.json │ │ │ ├── capabilities.json │ │ │ ├── desktop-schema.json │ │ │ └── macOS-schema.json │ │ ├── icons/ │ │ │ ├── android/ │ │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ │ └── ic_launcher.xml │ │ │ │ └── values/ │ │ │ │ └── ic_launcher_background.xml │ │ │ └── icon.icns │ │ ├── msi-template.wxs │ │ ├── splash/ │ │ │ └── index.html │ │ ├── src/ │ │ │ └── main.rs │ │ └── tauri.conf.json │ ├── trusted-key-auth/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ ├── key_confirmation.rs │ │ ├── lib.rs │ │ ├── refresh.rs │ │ ├── request_signature.rs │ │ ├── runtime.rs │ │ ├── spake2.rs │ │ └── trusted_keys.rs │ ├── utils/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── approvals.rs │ │ ├── assets.rs │ │ ├── browser.rs │ │ ├── command_ext.rs │ │ ├── diff.rs │ │ ├── execution_logs.rs │ │ ├── jwt.rs │ │ ├── lib.rs │ │ ├── log_msg.rs │ │ ├── msg_store.rs │ │ ├── path.rs │ │ ├── port_file.rs │ │ ├── process.rs │ │ ├── response.rs │ │ ├── sentry.rs │ │ ├── shell.rs │ │ ├── stream_lines.rs │ │ ├── text.rs │ │ ├── tokio.rs │ │ └── version.rs │ ├── workspace-manager/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ └── workspace_manager.rs │ └── worktree-manager/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ └── worktree_manager.rs ├── dev_assets_seed/ │ └── config.json ├── docs/ │ ├── .mintignore │ ├── AGENTS.md │ ├── README.md │ ├── agents/ │ │ ├── amp.mdx │ │ ├── ccr.mdx │ │ ├── claude-code.mdx │ │ ├── cursor-cli.mdx │ │ ├── droid.mdx │ │ ├── gemini-cli.mdx │ │ ├── github-copilot.mdx │ │ ├── openai-codex.mdx │ │ ├── opencode.mdx │ │ └── qwen-code.mdx │ ├── browser-testing.mdx │ ├── cloud/ │ │ ├── authentication.mdx │ │ ├── customisation.mdx │ │ ├── filtering.mdx │ │ ├── getting-started.mdx │ │ ├── index.mdx │ │ ├── issues.mdx │ │ ├── kanban-board.mdx │ │ ├── list-view.mdx │ │ ├── migration.mdx │ │ ├── organizations.mdx │ │ ├── projects.mdx │ │ ├── team-members.mdx │ │ └── troubleshooting.mdx │ ├── configuration-customisation/ │ │ ├── agent-configurations.mdx │ │ ├── creating-task-tags.mdx │ │ ├── global-settings.mdx │ │ └── keyboard-shortcuts.mdx │ ├── core-features/ │ │ ├── completing-a-task.mdx │ │ ├── creating-projects.mdx │ │ ├── creating-tasks.mdx │ │ ├── monitoring-task-execution.mdx │ │ ├── new-task-attempts.mdx │ │ ├── resolving-rebase-conflicts.mdx │ │ ├── reviewing-code-changes.mdx │ │ ├── subtasks.mdx │ │ └── testing-your-application.mdx │ ├── docs.json │ ├── frontend-ui-library-refactor-audit.md │ ├── getting-started.mdx │ ├── index.mdx │ ├── integrations/ │ │ ├── azure-repos-integration.mdx │ │ ├── github-integration.mdx │ │ ├── mcp-server-configuration.mdx │ │ ├── vibe-kanban-mcp-server.mdx │ │ └── vscode-extension.mdx │ ├── issue-management.mdx │ ├── remote-access.mdx │ ├── responsible-disclosure.mdx │ ├── reviewing-code.mdx │ ├── self-hosting/ │ │ ├── deploy-docker.mdx │ │ └── local-development.mdx │ ├── settings/ │ │ ├── agent-configurations.mdx │ │ ├── creating-task-tags.mdx │ │ ├── general.mdx │ │ ├── index.mdx │ │ ├── mcp-servers.mdx │ │ ├── organization-settings.mdx │ │ ├── projects-repositories.mdx │ │ └── remote-projects.mdx │ ├── supported-coding-agents.mdx │ ├── troubleshooting.mdx │ └── workspaces/ │ ├── changes.mdx │ ├── chat-interface.mdx │ ├── command-bar.mdx │ ├── creating-workspaces.mdx │ ├── git-operations.mdx │ ├── index.mdx │ ├── interface.mdx │ ├── managing-workspaces.mdx │ ├── multi-repo-sessions.mdx │ ├── repositories.mdx │ ├── sessions.mdx │ └── slash-commands.mdx ├── local-build.sh ├── mobile-testing.md ├── npx-cli/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── cli.ts │ │ ├── desktop.ts │ │ └── download.ts │ └── tsconfig.json ├── package.json ├── packages/ │ ├── local-web/ │ │ ├── .eslintrc.cjs │ │ ├── .prettierignore │ │ ├── .prettierrc.json │ │ ├── AGENTS.md │ │ ├── components.json │ │ ├── components.legacy.json │ │ ├── index.html │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── entry/ │ │ │ │ │ ├── App.tsx │ │ │ │ │ └── Bootstrap.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useTauriNotificationNavigation.ts │ │ │ │ │ └── useTauriUpdateReady.ts │ │ │ │ ├── navigation/ │ │ │ │ │ └── AppNavigation.ts │ │ │ │ ├── notifications/ │ │ │ │ │ ├── AppSystemNotifications.tsx │ │ │ │ │ └── showSystemNotification.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── ClickedElementsProvider.tsx │ │ │ │ │ ├── ConfigProvider.tsx │ │ │ │ │ └── ThemeProvider.tsx │ │ │ │ └── router/ │ │ │ │ └── index.ts │ │ │ ├── routeTree.gen.ts │ │ │ ├── routes/ │ │ │ │ ├── __root.tsx │ │ │ │ ├── _app.migrate.tsx │ │ │ │ ├── _app.notifications.tsx │ │ │ │ ├── _app.projects.$projectId.tsx │ │ │ │ ├── _app.projects.$projectId_.issues.$issueId.tsx │ │ │ │ ├── _app.projects.$projectId_.issues.$issueId_.workspaces.$workspaceId.tsx │ │ │ │ ├── _app.projects.$projectId_.issues.$issueId_.workspaces.create.$draftId.tsx │ │ │ │ ├── _app.projects.$projectId_.workspaces.create.$draftId.tsx │ │ │ │ ├── _app.tsx │ │ │ │ ├── _app.workspaces.tsx │ │ │ │ ├── _app.workspaces_.$workspaceId.tsx │ │ │ │ ├── _app.workspaces_.create.tsx │ │ │ │ ├── _app.workspaces_.electric-test.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── onboarding.tsx │ │ │ │ ├── onboarding_.sign-in.tsx │ │ │ │ └── workspaces.$workspaceId.vscode.tsx │ │ │ ├── shared/ │ │ │ │ └── types/ │ │ │ │ └── virtual-executor-schemas.d.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.new.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── public/ │ │ ├── robots.txt │ │ └── site.webmanifest │ ├── remote-web/ │ │ ├── .prettierignore │ │ ├── index.html │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── entry/ │ │ │ │ │ ├── App.tsx │ │ │ │ │ └── Bootstrap.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── RemoteAppBarUserPopoverContainer.tsx │ │ │ │ │ ├── RemoteAppShell.tsx │ │ │ │ │ ├── RemoteDesktopNavbar.tsx │ │ │ │ │ └── RemoteNavbarContainer.tsx │ │ │ │ ├── navigation/ │ │ │ │ │ └── AppNavigation.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── RemoteActionsProvider.tsx │ │ │ │ │ ├── RemoteAuthProvider.tsx │ │ │ │ │ └── RemoteUserSystemProvider.tsx │ │ │ │ ├── router/ │ │ │ │ │ └── index.ts │ │ │ │ └── styles/ │ │ │ │ └── index.css │ │ │ ├── pages/ │ │ │ │ ├── HomePage.tsx │ │ │ │ ├── InvitationCompletePage.tsx │ │ │ │ ├── InvitationPage.tsx │ │ │ │ ├── LoginCompletePage.tsx │ │ │ │ ├── LoginPage.tsx │ │ │ │ ├── NotFoundPage.tsx │ │ │ │ ├── RemoteProjectKanbanShell.tsx │ │ │ │ ├── RemoteWorkspacesPageShell.tsx │ │ │ │ ├── UpgradeCompletePage.tsx │ │ │ │ ├── UpgradePage.tsx │ │ │ │ ├── UpgradeSuccessPage.tsx │ │ │ │ └── WorkspacesUnavailablePage.tsx │ │ │ ├── routeTree.gen.ts │ │ │ ├── routes/ │ │ │ │ ├── __root.tsx │ │ │ │ ├── account.tsx │ │ │ │ ├── account_.complete.tsx │ │ │ │ ├── account_.organizations.$orgId.tsx │ │ │ │ ├── hosts.$hostId.workspaces.$workspaceId.vscode.tsx │ │ │ │ ├── hosts.$hostId.workspaces.tsx │ │ │ │ ├── hosts.$hostId.workspaces_.$workspaceId.tsx │ │ │ │ ├── hosts.$hostId.workspaces_.create.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── invitations.$token.accept.tsx │ │ │ │ ├── invitations.$token.complete.tsx │ │ │ │ ├── login.tsx │ │ │ │ ├── login_.complete.tsx │ │ │ │ ├── notifications.tsx │ │ │ │ ├── projects.$projectId.tsx │ │ │ │ ├── projects.$projectId_.hosts.$hostId.workspaces.create.$draftId.tsx │ │ │ │ ├── projects.$projectId_.issues.$issueId.tsx │ │ │ │ ├── projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.$workspaceId.tsx │ │ │ │ ├── projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.create.$draftId.tsx │ │ │ │ ├── upgrade.tsx │ │ │ │ ├── upgrade_.complete.tsx │ │ │ │ └── upgrade_.success.tsx │ │ │ ├── shared/ │ │ │ │ ├── components/ │ │ │ │ │ └── BrandLogo.tsx │ │ │ │ ├── constants/ │ │ │ │ │ └── settings.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useRelayAppBarHosts.ts │ │ │ │ │ ├── useRelayWorkspaceHostHealth.ts │ │ │ │ │ └── useSystemTheme.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── auth/ │ │ │ │ │ │ └── tokenManager.ts │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── pkce.ts │ │ │ │ │ ├── relay/ │ │ │ │ │ │ ├── activeHostContext.ts │ │ │ │ │ │ ├── bytes.ts │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ ├── http.ts │ │ │ │ │ │ ├── keyCache.ts │ │ │ │ │ │ ├── routing.ts │ │ │ │ │ │ ├── signing.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── ws.ts │ │ │ │ │ ├── relayHostApi.ts │ │ │ │ │ └── route-auth.ts │ │ │ │ ├── stores/ │ │ │ │ │ └── useMobileWorkspaceTitle.ts │ │ │ │ └── types/ │ │ │ │ └── virtual-executor-schemas.d.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.cjs │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── ui/ │ │ ├── .eslintrc.cjs │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Accordion.tsx │ │ │ │ ├── Alert.tsx │ │ │ │ ├── AppBar.tsx │ │ │ │ ├── AppBarButton.tsx │ │ │ │ ├── AppBarSocialLink.tsx │ │ │ │ ├── AppBarUserPopover.tsx │ │ │ │ ├── AskUserQuestionBanner.tsx │ │ │ │ ├── AutoExpandingTextarea.tsx │ │ │ │ ├── AutoResizeTextarea.tsx │ │ │ │ ├── Badge.tsx │ │ │ │ ├── BulkActionBar.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Card.tsx │ │ │ │ ├── ChangeTargetDialog.tsx │ │ │ │ ├── ChangesPanel.tsx │ │ │ │ ├── ChatAggregatedDiffEntries.tsx │ │ │ │ ├── ChatAggregatedToolEntries.tsx │ │ │ │ ├── ChatApprovalCard.tsx │ │ │ │ ├── ChatAssistantMessage.tsx │ │ │ │ ├── ChatBoxBase.tsx │ │ │ │ ├── ChatCollapsedThinking.tsx │ │ │ │ ├── ChatEmptyState.tsx │ │ │ │ ├── ChatEntryContainer.tsx │ │ │ │ ├── ChatErrorMessage.tsx │ │ │ │ ├── ChatFileEntry.tsx │ │ │ │ ├── ChatMarkdown.tsx │ │ │ │ ├── ChatScriptEntry.tsx │ │ │ │ ├── ChatScriptPlaceholder.tsx │ │ │ │ ├── ChatSubagentEntry.tsx │ │ │ │ ├── ChatSystemMessage.tsx │ │ │ │ ├── ChatThinkingMessage.tsx │ │ │ │ ├── ChatTodoList.tsx │ │ │ │ ├── ChatToolSummary.tsx │ │ │ │ ├── ChatUserMessage.tsx │ │ │ │ ├── Checkbox.tsx │ │ │ │ ├── ClickableCodePlugin.tsx │ │ │ │ ├── CodeBlockShortcutPlugin.tsx │ │ │ │ ├── CodeHighlightPlugin.tsx │ │ │ │ ├── CollapsibleSectionHeader.tsx │ │ │ │ ├── ColorPicker.tsx │ │ │ │ ├── Command.tsx │ │ │ │ ├── CommandBar.tsx │ │ │ │ ├── CommentCard.tsx │ │ │ │ ├── ComponentInfoKeyboardPlugin.tsx │ │ │ │ ├── ConfirmDialog.tsx │ │ │ │ ├── ContextBar.tsx │ │ │ │ ├── ContextUsageGauge.tsx │ │ │ │ ├── CreateChatBox.tsx │ │ │ │ ├── CreateRepoDialog.tsx │ │ │ │ ├── DataTable.tsx │ │ │ │ ├── DeleteWorkspaceDialog.tsx │ │ │ │ ├── Dialog.tsx │ │ │ │ ├── Dropdown.tsx │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ ├── EmojiPicker.tsx │ │ │ │ ├── ErrorAlert.tsx │ │ │ │ ├── ErrorDialog.tsx │ │ │ │ ├── FileTagTypeaheadPlugin.tsx │ │ │ │ ├── FileTree.tsx │ │ │ │ ├── FileTreeNode.tsx │ │ │ │ ├── FileTreeSearchBar.tsx │ │ │ │ ├── GitPanel.tsx │ │ │ │ ├── GoogleLogo.tsx │ │ │ │ ├── GuideDialogShell.tsx │ │ │ │ ├── IconButton.tsx │ │ │ │ ├── IconButtonGroup.tsx │ │ │ │ ├── ImageKeyboardPlugin.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── InputField.tsx │ │ │ │ ├── IssueCommentsSection.tsx │ │ │ │ ├── IssueListRow.tsx │ │ │ │ ├── IssueListSection.tsx │ │ │ │ ├── IssueListView.tsx │ │ │ │ ├── IssuePropertyRow.tsx │ │ │ │ ├── IssueRelationshipsSection.tsx │ │ │ │ ├── IssueSubIssuesSection.tsx │ │ │ │ ├── IssueTagsRow.tsx │ │ │ │ ├── IssueWorkspaceCard.tsx │ │ │ │ ├── IssueWorkspacesSection.tsx │ │ │ │ ├── KanbanAssignee.tsx │ │ │ │ ├── KanbanBadge.tsx │ │ │ │ ├── KanbanBoard.tsx │ │ │ │ ├── KanbanCardContent.tsx │ │ │ │ ├── KanbanFilterBar.tsx │ │ │ │ ├── KanbanIssuePanel.tsx │ │ │ │ ├── KeyboardCommandsPlugin.tsx │ │ │ │ ├── KeyboardDialog.tsx │ │ │ │ ├── Label.tsx │ │ │ │ ├── Loader.tsx │ │ │ │ ├── MarkdownInsertPlugin.tsx │ │ │ │ ├── MarkdownListContinuePlugin.tsx │ │ │ │ ├── MarkdownSyncPlugin.tsx │ │ │ │ ├── MigrateChooseProjects.tsx │ │ │ │ ├── MigrateFinish.tsx │ │ │ │ ├── MigrateIntroduction.tsx │ │ │ │ ├── MigrateMigrate.tsx │ │ │ │ ├── MigrateSidebar.tsx │ │ │ │ ├── MobileDrawer.tsx │ │ │ │ ├── ModelList.tsx │ │ │ │ ├── ModelProviderIcon.tsx │ │ │ │ ├── ModelSelectorPopover.tsx │ │ │ │ ├── MultiSelectCommandBar.tsx │ │ │ │ ├── MultiSelectDropdown.tsx │ │ │ │ ├── Navbar.tsx │ │ │ │ ├── OAuthButtons.tsx │ │ │ │ ├── PasteMarkdownPlugin.tsx │ │ │ │ ├── PierreConversationDiff.tsx │ │ │ │ ├── Popover.tsx │ │ │ │ ├── PrBadge.tsx │ │ │ │ ├── PreviewBrowser.tsx │ │ │ │ ├── PreviewControls.tsx │ │ │ │ ├── PreviewNavigation.tsx │ │ │ │ ├── PrimaryButton.tsx │ │ │ │ ├── PriorityFilterDropdown.tsx │ │ │ │ ├── PriorityIcon.tsx │ │ │ │ ├── ProcessListItem.tsx │ │ │ │ ├── ProjectsGuideDialog.tsx │ │ │ │ ├── PropertyDropdown.tsx │ │ │ │ ├── RadixTooltip.tsx │ │ │ │ ├── ReadOnlyLinkPlugin.tsx │ │ │ │ ├── RebaseInProgressDialog.tsx │ │ │ │ ├── RelationshipBadge.tsx │ │ │ │ ├── RenameSessionDialog.tsx │ │ │ │ ├── RenameWorkspaceDialog.tsx │ │ │ │ ├── RepoCard.tsx │ │ │ │ ├── RunningDots.tsx │ │ │ │ ├── SearchableDropdown.tsx │ │ │ │ ├── SearchableTagDropdown.tsx │ │ │ │ ├── Select.tsx │ │ │ │ ├── SessionChatBox.tsx │ │ │ │ ├── SlashCommandTypeaheadPlugin.tsx │ │ │ │ ├── SplitButton.tsx │ │ │ │ ├── StaticToolbarPlugin.tsx │ │ │ │ ├── StatusDot.tsx │ │ │ │ ├── SubIssueRow.tsx │ │ │ │ ├── Switch.tsx │ │ │ │ ├── SyncErrorIndicator.tsx │ │ │ │ ├── Table.tsx │ │ │ │ ├── TerminalPanel.tsx │ │ │ │ ├── Textarea.tsx │ │ │ │ ├── TodoProgressPopup.tsx │ │ │ │ ├── Toggle.tsx │ │ │ │ ├── ToolStatusDot.tsx │ │ │ │ ├── Toolbar.tsx │ │ │ │ ├── ToolbarPlugin.tsx │ │ │ │ ├── Tooltip.tsx │ │ │ │ ├── TypeaheadMenu.tsx │ │ │ │ ├── TypeaheadOpenContext.tsx │ │ │ │ ├── UserAvatar.tsx │ │ │ │ ├── ViewNavTabs.tsx │ │ │ │ ├── WorkspaceContext.tsx │ │ │ │ ├── WorkspaceSummary.tsx │ │ │ │ ├── WorkspacesMain.tsx │ │ │ │ ├── WorkspacesSidebar.tsx │ │ │ │ ├── attachment-node.tsx │ │ │ │ ├── component-info-node.tsx │ │ │ │ ├── create-decorator-node.tsx │ │ │ │ ├── image-node.tsx │ │ │ │ ├── pr-comment-card.tsx │ │ │ │ └── pr-comment-node.tsx │ │ │ ├── lib/ │ │ │ │ ├── cn.ts │ │ │ │ ├── code-highlight-theme.ts │ │ │ │ ├── modals.ts │ │ │ │ ├── platform.ts │ │ │ │ └── table-transformer.ts │ │ │ └── styles/ │ │ │ └── diff-style-overrides.css │ │ └── tsconfig.json │ └── web-core/ │ ├── .prettierrc.json │ ├── package.json │ ├── src/ │ │ ├── app/ │ │ │ └── styles/ │ │ │ ├── diff-style-overrides.css │ │ │ ├── edit-diff-overrides.css │ │ │ └── new/ │ │ │ └── index.css │ │ ├── features/ │ │ │ ├── create-mode/ │ │ │ │ └── model/ │ │ │ │ ├── CreateModeProvider.tsx │ │ │ │ ├── createModeBootstrap.ts │ │ │ │ ├── createModeSeedStore.ts │ │ │ │ ├── useCreateMode.ts │ │ │ │ └── useCreateModeState.ts │ │ │ ├── kanban/ │ │ │ │ ├── model/ │ │ │ │ │ └── hooks/ │ │ │ │ │ └── useKanbanFilters.ts │ │ │ │ └── ui/ │ │ │ │ ├── BulkActionBarContainer.tsx │ │ │ │ └── KanbanContainer.tsx │ │ │ ├── migration/ │ │ │ │ ├── model/ │ │ │ │ │ └── hooks/ │ │ │ │ │ └── useProjects.ts │ │ │ │ └── ui/ │ │ │ │ ├── MigrateChooseProjectsContainer.tsx │ │ │ │ ├── MigrateFinishContainer.tsx │ │ │ │ ├── MigrateIntroductionContainer.tsx │ │ │ │ ├── MigrateLayout.tsx │ │ │ │ └── MigrateMigrateContainer.tsx │ │ │ ├── onboarding/ │ │ │ │ └── ui/ │ │ │ │ ├── LandingPage.tsx │ │ │ │ └── OnboardingSignInPage.tsx │ │ │ ├── workspace/ │ │ │ │ └── model/ │ │ │ │ └── hooks/ │ │ │ │ ├── usePreviewDevServer.ts │ │ │ │ └── useWorkspaceNotes.ts │ │ │ └── workspace-chat/ │ │ │ ├── model/ │ │ │ │ ├── contexts/ │ │ │ │ │ ├── ApprovalFeedbackContext.tsx │ │ │ │ │ ├── EntriesContext.tsx │ │ │ │ │ ├── MessageEditContext.tsx │ │ │ │ │ └── RetryUiContext.tsx │ │ │ │ ├── conversation-row-model.ts │ │ │ │ ├── conversation-scroll-commands.ts │ │ │ │ ├── deriveConversationEntries.ts │ │ │ │ ├── deriveConversationSemanticTimeline.ts │ │ │ │ ├── deriveConversationTimeline.ts │ │ │ │ ├── deriveConversationTurns.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useApprovalMutation.ts │ │ │ │ │ ├── useConversationHistory.ts │ │ │ │ │ ├── useCreateSession.ts │ │ │ │ │ ├── useMessageEditRetry.ts │ │ │ │ │ ├── useResetProcess.ts │ │ │ │ │ ├── useResetProcessMutation.ts │ │ │ │ │ ├── useSessionAttachments.ts │ │ │ │ │ ├── useSessionMessageEditor.ts │ │ │ │ │ ├── useSessionQueueInteraction.ts │ │ │ │ │ ├── useSessionSend.ts │ │ │ │ │ ├── useTodos.ts │ │ │ │ │ └── useWorkspaceBranch.ts │ │ │ │ ├── store/ │ │ │ │ │ └── useInspectModeStore.ts │ │ │ │ ├── useConversationVirtualizer.ts │ │ │ │ └── useScrollCommandExecutor.ts │ │ │ └── ui/ │ │ │ ├── ConversationListContainer.tsx │ │ │ ├── DisplayConversationEntry.tsx │ │ │ └── SessionChatBoxContainer.tsx │ │ ├── i18n/ │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── languages.ts │ │ │ └── locales/ │ │ │ ├── en/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ ├── es/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ ├── fr/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ ├── ja/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ ├── ko/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ ├── zh-Hans/ │ │ │ │ ├── common.json │ │ │ │ ├── organization.json │ │ │ │ ├── projects.json │ │ │ │ ├── settings.json │ │ │ │ └── tasks.json │ │ │ └── zh-Hant/ │ │ │ ├── common.json │ │ │ ├── organization.json │ │ │ ├── projects.json │ │ │ ├── settings.json │ │ │ └── tasks.json │ │ ├── integrations/ │ │ │ ├── remote/ │ │ │ │ └── IssueProvider.tsx │ │ │ └── vscode/ │ │ │ ├── ContextMenu.tsx │ │ │ └── bridge.ts │ │ ├── mock/ │ │ │ └── normalized_entries.json │ │ ├── pages/ │ │ │ ├── kanban/ │ │ │ │ ├── IssueCommentsSectionContainer.tsx │ │ │ │ ├── IssueRelationshipsSectionContainer.tsx │ │ │ │ ├── IssueSubIssuesSectionContainer.tsx │ │ │ │ ├── IssueWorkspacesSectionContainer.tsx │ │ │ │ ├── KanbanIssuePanelContainer.tsx │ │ │ │ ├── LocalProjectKanban.tsx │ │ │ │ ├── ProjectKanban.tsx │ │ │ │ ├── ProjectRightSidebarContainer.tsx │ │ │ │ └── kanban-issue-panel-state.ts │ │ │ ├── migrate/ │ │ │ │ └── MigratePage.tsx │ │ │ ├── root/ │ │ │ │ └── RootRedirectPage.tsx │ │ │ └── workspaces/ │ │ │ ├── AppBarNotificationBellContainer.tsx │ │ │ ├── ChangesPanelContainer.tsx │ │ │ ├── CommentWidgetLine.tsx │ │ │ ├── ContextBarContainer.tsx │ │ │ ├── ElectricTestPage.tsx │ │ │ ├── FileTreeContainer.tsx │ │ │ ├── GitHubCommentRenderer.tsx │ │ │ ├── GitPanelContainer.tsx │ │ │ ├── LogsContentContainer.tsx │ │ │ ├── NotificationsPage.tsx │ │ │ ├── PierreDiffCard.tsx │ │ │ ├── PreviewBrowserContainer.tsx │ │ │ ├── PreviewControlsContainer.tsx │ │ │ ├── ProcessListContainer.tsx │ │ │ ├── ReviewCommentRenderer.tsx │ │ │ ├── RightSidebar.tsx │ │ │ ├── VSCodeWorkspacePage.tsx │ │ │ ├── WorkspaceNotesContainer.tsx │ │ │ ├── Workspaces.tsx │ │ │ ├── WorkspacesLanding.tsx │ │ │ ├── WorkspacesLayout.tsx │ │ │ ├── WorkspacesMainContainer.tsx │ │ │ └── WorkspacesSidebarContainer.tsx │ │ ├── project-routes/ │ │ │ ├── ProjectFallbackPage.tsx │ │ │ └── project-search.ts │ │ ├── shared/ │ │ │ ├── actions/ │ │ │ │ └── index.ts │ │ │ ├── command-bar/ │ │ │ │ └── actions/ │ │ │ │ ├── pages.ts │ │ │ │ └── useActionVisibility.ts │ │ │ ├── components/ │ │ │ │ ├── AgentIcon.tsx │ │ │ │ ├── CopyButton.tsx │ │ │ │ ├── CreateChatBoxContainer.tsx │ │ │ │ ├── CreateModeRepoPickerBar.tsx │ │ │ │ ├── IdeIcon.tsx │ │ │ │ ├── ModelSelectorContainer.tsx │ │ │ │ ├── NormalizedConversation/ │ │ │ │ │ ├── EditDiffRenderer.tsx │ │ │ │ │ ├── FileChangeRenderer.tsx │ │ │ │ │ ├── FileContentView.tsx │ │ │ │ │ ├── PendingApprovalEntry.tsx │ │ │ │ │ └── RetryEditorInline.tsx │ │ │ │ ├── OpenInIdeButton.tsx │ │ │ │ ├── RawLogText.tsx │ │ │ │ ├── SearchableTagDropdownContainer.tsx │ │ │ │ ├── SimpleMarkdown.tsx │ │ │ │ ├── TagManager.tsx │ │ │ │ ├── TerminalPanelContainer.tsx │ │ │ │ ├── VariantSelector.tsx │ │ │ │ ├── VirtualizedProcessLogs.tsx │ │ │ │ ├── WYSIWYGEditor.tsx │ │ │ │ ├── XTermInstance.tsx │ │ │ │ ├── common/ │ │ │ │ │ └── ProfileVariantBadge.tsx │ │ │ │ ├── org/ │ │ │ │ │ ├── MemberListItem.tsx │ │ │ │ │ └── PendingInvitationItem.tsx │ │ │ │ ├── settings/ │ │ │ │ │ └── ExecutorProfileSelector.tsx │ │ │ │ ├── tasks/ │ │ │ │ │ ├── AgentSelector.tsx │ │ │ │ │ ├── BranchSelector.tsx │ │ │ │ │ ├── ConfigSelector.tsx │ │ │ │ │ ├── RepoBranchSelector.tsx │ │ │ │ │ ├── RepoSelector.tsx │ │ │ │ │ ├── TaskDetails/ │ │ │ │ │ │ ├── ProcessLogsViewer.tsx │ │ │ │ │ │ └── ProcessesTab.tsx │ │ │ │ │ ├── Toolbar/ │ │ │ │ │ │ └── GitOperations.tsx │ │ │ │ │ └── UserAvatar.tsx │ │ │ │ └── ui-new/ │ │ │ │ └── containers/ │ │ │ │ ├── AppBarUserPopoverContainer.tsx │ │ │ │ ├── ColorPickerContainer.tsx │ │ │ │ ├── NavbarContainer.tsx │ │ │ │ ├── RemoteIssueLink.tsx │ │ │ │ ├── SearchableDropdownContainer.tsx │ │ │ │ └── SharedAppLayout.tsx │ │ │ ├── constants/ │ │ │ │ └── processes.ts │ │ │ ├── dialogs/ │ │ │ │ ├── auth/ │ │ │ │ │ └── GhCliSetupDialog.tsx │ │ │ │ ├── command-bar/ │ │ │ │ │ ├── BranchRebaseDialog.tsx │ │ │ │ │ ├── ChangeTargetBranchDialog.tsx │ │ │ │ │ ├── CommandBarDialog.tsx │ │ │ │ │ ├── CreatePRDialog.tsx │ │ │ │ │ ├── CreateWorkspaceFromPrDialog.tsx │ │ │ │ │ ├── EditBranchNameDialog.tsx │ │ │ │ │ ├── EditorSelectionDialog.tsx │ │ │ │ │ ├── ForcePushDialog.tsx │ │ │ │ │ ├── GitActionsDialog.tsx │ │ │ │ │ ├── RebaseDialog.tsx │ │ │ │ │ ├── SelectionDialog.tsx │ │ │ │ │ ├── StartReviewDialog.tsx │ │ │ │ │ ├── ViewProcessesDialog.tsx │ │ │ │ │ ├── WorkspaceSelectionDialog.tsx │ │ │ │ │ ├── commandBar/ │ │ │ │ │ │ ├── injectSearchMatches.ts │ │ │ │ │ │ ├── useCommandBarState.ts │ │ │ │ │ │ └── useResolvedPage.ts │ │ │ │ │ └── selections/ │ │ │ │ │ ├── ProjectSelectionDialog.tsx │ │ │ │ │ ├── branchSelection.ts │ │ │ │ │ ├── prioritySelection.ts │ │ │ │ │ ├── relationshipSelection.ts │ │ │ │ │ ├── repoSelection.ts │ │ │ │ │ ├── statusSelection.ts │ │ │ │ │ └── subIssueSelection.ts │ │ │ │ ├── global/ │ │ │ │ │ ├── OAuthDialog.tsx │ │ │ │ │ └── ReleaseNotesDialog.tsx │ │ │ │ ├── kanban/ │ │ │ │ │ ├── AssigneeSelectionDialog.tsx │ │ │ │ │ └── KanbanFiltersDialog.tsx │ │ │ │ ├── org/ │ │ │ │ │ ├── CreateOrganizationDialog.tsx │ │ │ │ │ ├── CreateRemoteProjectDialog.tsx │ │ │ │ │ ├── DeleteRemoteProjectDialog.tsx │ │ │ │ │ └── InviteMemberDialog.tsx │ │ │ │ ├── scripts/ │ │ │ │ │ └── ScriptFixerDialog.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── CreateConfigurationDialog.tsx │ │ │ │ │ ├── DeleteConfigurationDialog.tsx │ │ │ │ │ ├── SettingsDialog.tsx │ │ │ │ │ └── settings/ │ │ │ │ │ ├── AgentsSettingsSection.tsx │ │ │ │ │ ├── ExecutorConfigForm.tsx │ │ │ │ │ ├── GeneralSettingsSection.tsx │ │ │ │ │ ├── McpSettingsSection.tsx │ │ │ │ │ ├── OrganizationsSettingsSection.tsx │ │ │ │ │ ├── RelaySettingsSection.tsx │ │ │ │ │ ├── RemoteProjectsSettingsSection.tsx │ │ │ │ │ ├── ReposSettingsSection.tsx │ │ │ │ │ ├── SettingsComponents.tsx │ │ │ │ │ ├── SettingsDirtyContext.tsx │ │ │ │ │ ├── SettingsSection.tsx │ │ │ │ │ ├── rjsf/ │ │ │ │ │ │ ├── Fields.tsx │ │ │ │ │ │ ├── Templates.tsx │ │ │ │ │ │ ├── Widgets.tsx │ │ │ │ │ │ └── theme.ts │ │ │ │ │ └── useRelayRemoteHostMutations.ts │ │ │ │ ├── shared/ │ │ │ │ │ ├── ConfirmDialog.tsx │ │ │ │ │ ├── FolderPickerDialog.tsx │ │ │ │ │ ├── KeyboardShortcutsDialog.tsx │ │ │ │ │ ├── LoginRequiredPrompt.tsx │ │ │ │ │ ├── TagEditDialog.tsx │ │ │ │ │ └── WorkspacesGuideDialog.tsx │ │ │ │ ├── tasks/ │ │ │ │ │ ├── PrCommentsDialog.tsx │ │ │ │ │ ├── ResolveConflictsDialog.tsx │ │ │ │ │ └── RestoreLogsDialog.tsx │ │ │ │ └── wysiwyg/ │ │ │ │ └── ImagePreviewDialog.tsx │ │ │ ├── hooks/ │ │ │ │ ├── ApprovalForm.tsx │ │ │ │ ├── ChangesViewProvider.tsx │ │ │ │ ├── GitOperationsContext.tsx │ │ │ │ ├── ProcessSelectionContext.tsx │ │ │ │ ├── ReviewProvider.tsx │ │ │ │ ├── TabNavigationContext.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── useAuth.ts │ │ │ │ │ ├── useAuthMutations.ts │ │ │ │ │ ├── useAuthStatus.ts │ │ │ │ │ └── useCurrentUser.ts │ │ │ │ ├── organizationKeys.ts │ │ │ │ ├── useActionVisibilityContext.ts │ │ │ │ ├── useActions.ts │ │ │ │ ├── useAllOrganizationProjects.ts │ │ │ │ ├── useAppNavigation.ts │ │ │ │ ├── useAppRuntime.tsx │ │ │ │ ├── useApprovals.ts │ │ │ │ ├── useAttachmentUrl.ts │ │ │ │ ├── useAzureAttachments.ts │ │ │ │ ├── useBranchStatus.ts │ │ │ │ ├── useChangeTargetBranch.ts │ │ │ │ ├── useChangesView.ts │ │ │ │ ├── useCommandBarShortcut.ts │ │ │ │ ├── useContextBarPosition.ts │ │ │ │ ├── useConversationHistory/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── useCreateAttachments.ts │ │ │ │ ├── useCreateWorkspace.ts │ │ │ │ ├── useCurrentAppDestination.ts │ │ │ │ ├── useCurrentKanbanRouteState.ts │ │ │ │ ├── useDebouncedCallback.ts │ │ │ │ ├── useDevServer.ts │ │ │ │ ├── useDiffStream.ts │ │ │ │ ├── useDiffSummary.ts │ │ │ │ ├── useDiscordOnlineCount.ts │ │ │ │ ├── useExecutionProcesses.ts │ │ │ │ ├── useExecutionProcessesContext.ts │ │ │ │ ├── useExecutorConfig.ts │ │ │ │ ├── useExecutorDiscovery.ts │ │ │ │ ├── useForcePush.ts │ │ │ │ ├── useGitHubComments.ts │ │ │ │ ├── useGitHubStars.ts │ │ │ │ ├── useGitOperations.ts │ │ │ │ ├── useIsMobile.ts │ │ │ │ ├── useIssueContext.ts │ │ │ │ ├── useIssueMultiSelect.ts │ │ │ │ ├── useJsonPatchWsStream.ts │ │ │ │ ├── useKanbanIssueComposerScratch.ts │ │ │ │ ├── useLocalStorageScratch.ts │ │ │ │ ├── useLogStream.ts │ │ │ │ ├── useLogsPanel.ts │ │ │ │ ├── useMerge.ts │ │ │ │ ├── useNotificationMembers.ts │ │ │ │ ├── useNotifications.ts │ │ │ │ ├── useOpenInEditor.ts │ │ │ │ ├── useOrgContext.ts │ │ │ │ ├── useOrganizationInvitations.ts │ │ │ │ ├── useOrganizationMembers.ts │ │ │ │ ├── useOrganizationMutations.ts │ │ │ │ ├── useOrganizationProjects.ts │ │ │ │ ├── useOrganizationSelection.ts │ │ │ │ ├── usePageTitle.ts │ │ │ │ ├── usePrComments.ts │ │ │ │ ├── usePresetOptions.ts │ │ │ │ ├── usePreviewNavigation.ts │ │ │ │ ├── usePreviewSettings.ts │ │ │ │ ├── usePreviewUrl.ts │ │ │ │ ├── useProfiles.ts │ │ │ │ ├── useProjectContext.ts │ │ │ │ ├── useProjectRepoDefaults.ts │ │ │ │ ├── useProjectWorkspaceCreateDraft.ts │ │ │ │ ├── usePush.ts │ │ │ │ ├── useRebase.ts │ │ │ │ ├── useReleases.ts │ │ │ │ ├── useRenameBranch.ts │ │ │ │ ├── useRepoBranchSelection.ts │ │ │ │ ├── useRepoBranches.ts │ │ │ │ ├── useRetryProcess.ts │ │ │ │ ├── useRetryUi.ts │ │ │ │ ├── useReview.ts │ │ │ │ ├── useScratch.ts │ │ │ │ ├── useScriptPlaceholders.ts │ │ │ │ ├── useScrollSyncStateMachine.ts │ │ │ │ ├── useSyncErrorContext.ts │ │ │ │ ├── useTaskWorkspaces.ts │ │ │ │ ├── useTerminal.ts │ │ │ │ ├── useTheme.ts │ │ │ │ ├── useUiPreferencesScratch.ts │ │ │ │ ├── useUserContext.ts │ │ │ │ ├── useUserOrganizations.ts │ │ │ │ ├── useUserSystem.ts │ │ │ │ ├── useVariant.ts │ │ │ │ ├── useWorkspace.ts │ │ │ │ ├── useWorkspaceConflicts.ts │ │ │ │ ├── useWorkspaceContext.ts │ │ │ │ ├── useWorkspaceCreateDefaults.ts │ │ │ │ ├── useWorkspaceExecution.ts │ │ │ │ ├── useWorkspaceRecord.ts │ │ │ │ ├── useWorkspaceRepo.ts │ │ │ │ ├── useWorkspaceSessions.ts │ │ │ │ ├── useWorkspaceSidebarPreviewController.ts │ │ │ │ ├── useWorkspaces.ts │ │ │ │ └── workspaceSummaryKeys.ts │ │ │ ├── integrations/ │ │ │ │ └── electric/ │ │ │ │ └── hooks.ts │ │ │ ├── keyboard/ │ │ │ │ ├── SequenceIndicator.tsx │ │ │ │ ├── SequenceTracker.tsx │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ ├── registry.ts │ │ │ │ ├── types.ts │ │ │ │ ├── useIssueShortcuts.ts │ │ │ │ ├── useSemanticKey.ts │ │ │ │ └── useWorkspaceShortcuts.ts │ │ │ ├── lib/ │ │ │ │ ├── StyleOverride.tsx │ │ │ │ ├── TruncatePath.tsx │ │ │ │ ├── aggregateEntries.ts │ │ │ │ ├── api.ts │ │ │ │ ├── attachmentUtils.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── runtime.ts │ │ │ │ │ └── tokenManager.ts │ │ │ │ ├── clipboard.ts │ │ │ │ ├── colors.ts │ │ │ │ ├── conflicts.ts │ │ │ │ ├── date.ts │ │ │ │ ├── devServerUtils.ts │ │ │ │ ├── diffDataAdapter.ts │ │ │ │ ├── diffHeightEstimate.ts │ │ │ │ ├── diffStatsParser.ts │ │ │ │ ├── electric/ │ │ │ │ │ ├── collections.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── executor.ts │ │ │ │ ├── extToLanguage.ts │ │ │ │ ├── fileTreeUtils.ts │ │ │ │ ├── fileTypeIcon.ts │ │ │ │ ├── firstProjectDestination.ts │ │ │ │ ├── hmrContext.ts │ │ │ │ ├── id.ts │ │ │ │ ├── ideName.ts │ │ │ │ ├── jsonPatch.ts │ │ │ │ ├── localApiTransport.ts │ │ │ │ ├── mcpStrategies.ts │ │ │ │ ├── modals.ts │ │ │ │ ├── modelSelector.ts │ │ │ │ ├── notificationMessage.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── paths.ts │ │ │ │ ├── platform.ts │ │ │ │ ├── previewBridge.ts │ │ │ │ ├── previewDevToolsBridge.ts │ │ │ │ ├── projectOrder.ts │ │ │ │ ├── promptMessage.ts │ │ │ │ ├── queryClient.ts │ │ │ │ ├── recentModels.ts │ │ │ │ ├── relayBackendApi.ts │ │ │ │ ├── relayClientIdentity.ts │ │ │ │ ├── relayPairingStorage.ts │ │ │ │ ├── relayPake.ts │ │ │ │ ├── relaySigningSessionRefresh.ts │ │ │ │ ├── remoteApi.ts │ │ │ │ ├── resolveRelationships.ts │ │ │ │ ├── routes/ │ │ │ │ │ └── appNavigation.ts │ │ │ │ ├── scriptPlaceholders.ts │ │ │ │ ├── searchTagsAndFiles.ts │ │ │ │ ├── streamJsonPatchEntries.ts │ │ │ │ ├── string.ts │ │ │ │ ├── terminalTheme.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── types.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── virtuoso-modifiers.ts │ │ │ │ ├── workspaceAttachments.ts │ │ │ │ ├── workspaceCreateState.ts │ │ │ │ ├── workspaceDefaults.ts │ │ │ │ └── zoom.ts │ │ │ ├── providers/ │ │ │ │ ├── ActionsProvider.tsx │ │ │ │ ├── ExecutionProcessesProvider.tsx │ │ │ │ ├── LogsPanelProvider.tsx │ │ │ │ ├── SyncErrorProvider.tsx │ │ │ │ ├── TerminalProvider.tsx │ │ │ │ ├── WorkspaceProvider.tsx │ │ │ │ ├── auth/ │ │ │ │ │ └── LocalAuthProvider.tsx │ │ │ │ └── remote/ │ │ │ │ ├── OrgProvider.tsx │ │ │ │ ├── ProjectProvider.tsx │ │ │ │ └── UserProvider.tsx │ │ │ ├── stores/ │ │ │ │ ├── useAppUpdateStore.ts │ │ │ │ ├── useDiffViewStore.ts │ │ │ │ ├── useExpandableStore.ts │ │ │ │ ├── useIssueSelectionStore.ts │ │ │ │ ├── useKanbanIssueComposerStore.ts │ │ │ │ ├── useOrganizationStore.ts │ │ │ │ └── useUiPreferencesStore.ts │ │ │ └── types/ │ │ │ ├── actions.ts │ │ │ ├── attempt.ts │ │ │ ├── commandBar.ts │ │ │ ├── createMode.ts │ │ │ ├── diff.ts │ │ │ ├── fileTree.ts │ │ │ ├── logs.ts │ │ │ ├── modal-args.d.ts │ │ │ ├── modals.ts │ │ │ ├── previewDevTools.ts │ │ │ ├── selectionItems.ts │ │ │ ├── tabs.ts │ │ │ ├── tanstack-history.d.ts │ │ │ └── virtual-executor-schemas.d.ts │ │ ├── styles/ │ │ │ ├── diff-style-overrides.css │ │ │ ├── edit-diff-overrides.css │ │ │ └── new/ │ │ │ └── index.css │ │ ├── test/ │ │ │ └── fixtures/ │ │ │ └── normalized_entries.json │ │ └── vite-env.d.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── rust-toolchain.toml ├── rustfmt.toml ├── scripts/ │ ├── build-bippy-bundle.mjs │ ├── build-tauri-msi.js │ ├── check-i18n.sh │ ├── check-legacy-frontend-paths.sh │ ├── check-unused-i18n-keys.mjs │ ├── clang │ ├── dialog-import-rewrite-map.tsv │ ├── generate-desktop-manifest.js │ ├── generate-tauri-update-json.js │ ├── legacy-frontend-paths-allowlist.txt │ ├── migrate-remote-web-structure.mjs │ ├── prepare-db.js │ ├── refactor-web-shims.mjs │ ├── relay-test-client/ │ │ ├── README.md │ │ └── index.html │ ├── ring-cc-wrapper.sh │ └── setup-dev-environment.js └── shared/ ├── jwt.ts ├── remote-types.ts ├── schemas/ │ ├── amp.json │ ├── claude_code.json │ ├── codex.json │ ├── copilot.json │ ├── cursor_agent.json │ ├── droid.json │ ├── gemini.json │ ├── opencode.json │ └── qwen_code.json └── types.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.x86_64-apple-darwin] rustflags = [ "-C", "link-arg=-framework", "-C", "link-arg=AppKit", "-C", "link-arg=-framework", "-C", "link-arg=ApplicationServices", "-C", "link-arg=-framework", "-C", "link-arg=Foundation", ] [target.aarch64-apple-darwin] rustflags = [ "-C", "link-arg=-framework", "-C", "link-arg=AppKit", "-C", "link-arg=-framework", "-C", "link-arg=ApplicationServices", "-C", "link-arg=-framework", "-C", "link-arg=Foundation", ] [target.x86_64-pc-windows-msvc] rustflags = ["-C", "link-arg=/DEBUG:FASTLINK"] [target.aarch64-pc-windows-msvc] rustflags = ["-C", "link-arg=/DEBUG:FASTLINK"] ================================================ FILE: .dockerignore ================================================ # Node modules node_modules/ **/node_modules .pnpm-store/ .pnpm/ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Build artifacts **/target/ dist/ build/ *.tgz *.tar.gz remote-frontend/dist/ # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Git .git/ .gitignore # Repo content not needed for Docker builds .github/ docs/ dev_assets/ dev_assets_seed/ # Docker Dockerfile* .dockerignore # Environment files .env .env.local .env.development.local .env.test.local .env.production.local # Logs logs/ *.log # Runtime data pids/ *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ # Temporary folders tmp/ temp/ ================================================ FILE: .github/actions/cargo-checks-common-setup/action.yml ================================================ name: 'Cargo checks common setup' description: 'Common setup for cargo checks in CI' inputs: toolchain: description: 'Rust toolchain version' required: true components: description: 'Comma-separated rustup components' required: false default: '' cache-key: description: 'Shared key for rust-cache' required: true setup-node: description: 'Whether to install Node.js and pnpm' required: false default: 'false' setup-sqlx-cli: description: 'Whether to install sqlx-cli' required: false default: 'false' runs: using: 'composite' steps: - name: Setup Node if: ${{ inputs.setup-node == 'true' }} uses: ./.github/actions/setup-node - name: Setup sccache uses: BloopAI/sccache-action@main - name: Install Rust toolchain if: ${{ inputs.components == '' }} uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ inputs.toolchain }} - name: Install Rust toolchain with components if: ${{ inputs.components != '' }} uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ inputs.toolchain }} components: ${{ inputs.components }} - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 env: RUST_CACHE_DEBUG: true with: workspaces: "." cache-provider: "github" cache-on-failure: true shared-key: ${{ inputs.cache-key }} cache-all-crates: true - name: Install sqlx-cli if: ${{ inputs.setup-sqlx-cli == 'true' }} uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1 with: tool: sqlx-cli no-default-features: true features: sqlite,postgres ================================================ FILE: .github/actions/setup-jsign/action.yml ================================================ name: 'Setup Jsign' description: 'Downloads and caches Jsign for cross-platform Windows code signing' inputs: version: description: 'Jsign version' required: false default: '7.4' sha256: description: 'Expected SHA256 of the jar' required: false default: '2abf2ade9ea322acc2d60c24794eadc465ff9380938fca4c932d09e0b25f1c28' outputs: jar-path: description: 'Path to the Jsign jar' value: ${{ steps.path.outputs.jar }} runs: using: 'composite' steps: - name: Set jar path id: path shell: bash run: echo "jar=${{ runner.temp }}/jsign-${{ inputs.version }}.jar" >> $GITHUB_OUTPUT - name: Cache Jsign id: cache uses: actions/cache@v5 with: path: ${{ steps.path.outputs.jar }} key: jsign-${{ inputs.version }}-${{ inputs.sha256 }} - name: Download Jsign if: steps.cache.outputs.cache-hit != 'true' shell: bash run: | curl -sL "https://github.com/ebourg/jsign/releases/download/${{ inputs.version }}/jsign-${{ inputs.version }}.jar" \ -o "${{ steps.path.outputs.jar }}" - name: Verify checksum shell: bash run: echo "${{ inputs.sha256 }} ${{ steps.path.outputs.jar }}" | sha256sum -c - ================================================ FILE: .github/actions/setup-node/action.yml ================================================ name: 'Setup Node.js and pnpm' description: 'Sets up Node.js and pnpm with caching' runs: using: 'composite' steps: - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@v4 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-pnpm-store- ================================================ FILE: .github/workflows/pre-release.yml ================================================ name: Create GitHub Pre-Release on: workflow_dispatch: inputs: version_type: description: "Version bump type" required: true default: "patch" type: choice options: - patch - minor - major - prerelease concurrency: group: release-${{ github.ref_name }} # allow concurrent prerelease from different branches cancel-in-progress: true permissions: contents: write packages: write pull-requests: write env: NODE_VERSION: 22 PNPM_VERSION: 10.13.1 RUST_TOOLCHAIN: nightly-2025-12-04 CARGO_XWIN_VERSION: 0.20.2 jobs: bump-version: runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: new_tag: ${{ steps.version.outputs.new_tag }} new_version: ${{ steps.version.outputs.new_version }} branch_suffix: ${{ steps.branch.outputs.suffix }} steps: - name: Cache cargo-edit uses: actions/cache@v5 id: cache-cargo-edit with: path: ~/.cargo/bin/cargo-set-version key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }} - name: Install cargo-edit if: steps.cache-cargo-edit.outputs.cache-hit != 'true' run: cargo install cargo-edit - uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ssh-key: ${{ secrets.DEPLOY_KEY }} - name: setup node uses: ./.github/actions/setup-node - name: Setup SSH Agent for private dependencies id: ssh-setup if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }} - name: Generate branch suffix id: branch run: | branch_name="${{ github.ref_name }}" # Get last 6 characters of branch name, remove all special chars (including dashes) suffix=$(echo "$branch_name" | tail -c 7 | sed 's/[^a-zA-Z0-9]//g' | tr '[:upper:]' '[:lower:]') echo "Branch: $branch_name" echo "Suffix: $suffix" echo "suffix=$suffix" >> $GITHUB_OUTPUT - name: Determine and update versions id: version run: | # Get the latest version from npm registry latest_npm_version=$(npm view vibe-kanban version 2>/dev/null || echo "0.0.0") echo "Latest npm version: $latest_npm_version" # Get current repo version current_repo_version=$(node -p "require('./package.json').version") echo "Current repo version: $current_repo_version" # Use the higher of the two versions as the base (prevents downgrade errors with cargo set-version) base_version=$(node -e " const npm = '$latest_npm_version'.split('.').map(Number); const repo = '$current_repo_version'.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((npm[i] || 0) > (repo[i] || 0)) { console.log('$latest_npm_version'); process.exit(); } if ((npm[i] || 0) < (repo[i] || 0)) { console.log('$current_repo_version'); process.exit(); } } console.log('$current_repo_version'); ") echo "Base version for bump: $base_version" timestamp=$(date +%Y%m%d%H%M%S) # Update root package.json based on base version if [[ "${{ github.event.inputs.version_type }}" == "prerelease" ]]; then # For prerelease, use current package.json version and add branch suffix npm version prerelease --preid="${{ steps.branch.outputs.suffix }}" --no-git-tag-version new_version=$(node -p "require('./package.json').version") new_tag="v${new_version}.${timestamp}" else # For regular releases, use base version and bump it npm version $base_version --no-git-tag-version --allow-same-version npm version ${{ github.event.inputs.version_type }} --no-git-tag-version new_version=$(node -p "require('./package.json').version") new_tag="v${new_version}-${timestamp}" fi # Update npx-cli package.json to match ( cd npx-cli npm version $new_version --no-git-tag-version --allow-same-version ) # Update web app package.json to match ( cd packages/local-web npm version $new_version --no-git-tag-version --allow-same-version ) cargo set-version --workspace "$new_version" node -e " const fs = require('fs'); const path = 'crates/tauri-app/tauri.conf.json'; const conf = JSON.parse(fs.readFileSync(path, 'utf8')); conf.version = '$new_version'; fs.writeFileSync(path, JSON.stringify(conf, null, 2) + '\n'); " echo "New version: $new_version" echo "new_version=$new_version" >> $GITHUB_OUTPUT echo "new_tag=$new_tag" >> $GITHUB_OUTPUT - name: Update remote crate lockfile if: ${{ steps.ssh-setup.outcome == 'success' }} run: cargo metadata --format-version 1 --manifest-path crates/remote/Cargo.toml > /dev/null - name: Update relay-tunnel crate lockfile run: cargo metadata --format-version 1 --manifest-path crates/relay-tunnel/Cargo.toml > /dev/null - name: Stop SSH agent if: ${{ steps.ssh-setup.outcome == 'success' }} run: ssh-agent -k - name: Commit changes and create tag run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add package.json pnpm-lock.yaml npx-cli/package.json npx-cli/package-lock.json packages/local-web/package.json crates/tauri-app/tauri.conf.json Cargo.lock git add $(find . -name Cargo.toml) [ -f crates/remote/Cargo.lock ] && git add crates/remote/Cargo.lock || true [ -f crates/relay-tunnel/Cargo.lock ] && git add crates/relay-tunnel/Cargo.lock || true git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}" git tag -a ${{ steps.version.outputs.new_tag }} -m "Release ${{ steps.version.outputs.new_tag }}" git push git push --tags build-frontend: needs: bump-version runs-on: blacksmith-16vcpu-ubuntu-2404 env: VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY: ${{ secrets.PUBLIC_REACT_VIRTUOSO_LICENSE_KEY }} VITE_VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Setup Node uses: ./.github/actions/setup-node - name: Install dependencies run: pnpm install - name: Lint frontend run: cd packages/local-web && npm run lint - name: Type check frontend run: cd packages/local-web && npx tsc --noEmit - name: Build frontend run: cd packages/local-web && npm run build env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} VITE_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} VITE_POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Create Sentry release uses: getsentry/action-release@v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} with: release: ${{ needs.bump-version.outputs.new_version }} environment: production sourcemaps: "./packages/local-web/dist" ignore_missing: true - name: Upload frontend artifact uses: actions/upload-artifact@v6 with: name: frontend-dist path: packages/local-web/dist/ retention-days: 1 build-backend: needs: [bump-version, build-frontend] runs-on: ${{ matrix.os }} strategy: # Platform matrix - keep target/name in sync with package-npx-cli job matrix: include: - target: x86_64-unknown-linux-musl os: blacksmith-16vcpu-ubuntu-2404 name: linux-x64 - target: aarch64-unknown-linux-musl os: blacksmith-16vcpu-ubuntu-2404-arm name: linux-arm64 - target: x86_64-pc-windows-msvc os: blacksmith-16vcpu-ubuntu-2404 name: windows-x64 - target: x86_64-apple-darwin os: macos-15-xlarge name: macos-x64 - target: aarch64-apple-darwin os: macos-15-xlarge name: macos-arm64 - target: aarch64-pc-windows-msvc os: blacksmith-16vcpu-ubuntu-2404 name: windows-arm64 env: CARGO_INCREMENTAL: "0" SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" SCCACHE_CACHE_SIZE: "10G" CARGO_HOME: ${{ github.workspace }}/.cargo RUSTUP_HOME: ${{ github.workspace }}/.rustup XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Setup sccache uses: BloopAI/sccache-action@main - name: Cache Rust toolchain uses: actions/cache@v5 with: path: .rustup/toolchains key: rust-toolchain-${{ runner.os }}-${{ matrix.target }}-${{ env.RUST_TOOLCHAIN }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} targets: ${{ matrix.target }} components: rustfmt, clippy - name: Install dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y clang libclang-dev lld llvm nasm cmake ninja-build if [[ "${{ matrix.target }}" == *"windows"* ]]; then DEBIAN_FRONTEND=noninteractive sudo apt-get install -y clang-19 clang-tools-19 llvm-19 lld-19 echo "/usr/lib/llvm-19/bin" >> $GITHUB_PATH fi - name: Cache Cargo registry uses: actions/cache@v5 with: path: | .cargo/registry/cache .cargo/registry/index .cargo/git/db .cargo/bin .cargo/.crates.toml .cargo/.crates2.json key: cargo-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | cargo-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}- - name: Install Zig if: runner.os == 'Linux' && !contains(matrix.target, 'windows') uses: BloopAI/setup-zig@main with: version: 0.15.2 - name: Install cargo zigbuild if: runner.os == 'Linux' && !contains(matrix.target, 'windows') run: cargo install --locked cargo-zigbuild --version 0.20.1 - name: Install cargo xwin if: runner.os == 'Linux' && contains(matrix.target, 'windows') run: cargo install --locked cargo-xwin --version ${{ env.CARGO_XWIN_VERSION }} - name: Cache xwin downloads if: runner.os == 'Linux' && contains(matrix.target, 'windows') uses: actions/cache@v5 with: path: ${{ github.workspace }}/.xwin-cache key: xwin-${{ runner.os }}-${{ matrix.target }}-cargo-xwin-${{ env.CARGO_XWIN_VERSION }} - name: Cache target uses: actions/cache@v5 with: path: target key: target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-${{ github.sha }} restore-keys: | target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}- target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}- - name: Setup cargo-sweep shell: bash run: | cargo install --locked cargo-sweep --version 0.8.0 - name: Download frontend artifact uses: actions/download-artifact@v7 with: name: frontend-dist path: packages/local-web/dist/ - name: Build backend (Linux) if: runner.os == 'Linux' && !contains(matrix.target, 'windows') run: | cargo zigbuild --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Build backend (macOS) if: runner.os == 'macOS' run: | if [[ "${{ matrix.target }}" == "x86_64-apple-darwin" ]]; then export MACOSX_DEPLOYMENT_TARGET=10.12 elif [[ "${{ matrix.target }}" == "aarch64-apple-darwin" ]]; then export MACOSX_DEPLOYMENT_TARGET=11.0 fi cargo build --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Build backend (Windows) if: runner.os == 'Linux' && contains(matrix.target, 'windows') run: | if [[ "${{ matrix.target }}" == "aarch64-pc-windows-msvc" ]]; then # ring requires clang on arm64 windows. See https://github.com/briansmith/ring/issues/2117 chmod +x scripts/ring-cc-wrapper.sh scripts/clang export PATH="${{ github.workspace }}/scripts:$PATH" export RING_CC=/usr/lib/llvm-19/bin/clang export DEFAULT_CC=clang-cl export CC_aarch64_pc_windows_msvc="${{ github.workspace }}/scripts/ring-cc-wrapper.sh" fi cargo xwin build --cross-compiler clang-cl --release --target ${{ matrix.target }} -p server -p mcp -p review --bin server --bin vibe-kanban-mcp --bin review env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} # Avoid aws-lc-sys CMake failures when Rust's `release` profile includes debug info. # Without this, cmake-rs selects RelWithDebInfo and CMake fails when ASM is enabled. CARGO_PROFILE_RELEASE_DEBUG: 0 - name: Setup Sentry CLI uses: matbour/setup-sentry-cli@v2 with: token: ${{ secrets.SENTRY_AUTH_TOKEN }} organization: ${{ secrets.SENTRY_ORG }} project: ${{ secrets.SENTRY_PROJECT }} version: 2.21.2 - name: Upload source maps to Sentry run: sentry-cli debug-files upload --include-sources target/${{ matrix.target }}/release - name: Prepare binaries (non-macOS) if: runner.os != 'macOS' shell: bash run: | mkdir -p dist if [[ "${{ matrix.name }}" == *"windows"* ]]; then cp target/${{ matrix.target }}/release/server.exe dist/vibe-kanban-${{ matrix.name }}.exe cp target/${{ matrix.target }}/release/vibe-kanban-mcp.exe dist/vibe-kanban-mcp-${{ matrix.name }}.exe cp target/${{ matrix.target }}/release/review.exe dist/vibe-kanban-review-${{ matrix.name }}.exe else cp target/${{ matrix.target }}/release/server dist/vibe-kanban-${{ matrix.name }} cp target/${{ matrix.target }}/release/vibe-kanban-mcp dist/vibe-kanban-mcp-${{ matrix.name }} cp target/${{ matrix.target }}/release/review dist/vibe-kanban-review-${{ matrix.name }} fi # Code signing for macOS only - name: Prepare Apple certificate (macOS) if: runner.os == 'macOS' run: | echo "${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}" | base64 --decode > certificate.p12 - name: Write API Key to file if: runner.os == 'macOS' env: API_KEY: ${{ secrets.APP_STORE_API_KEY }} run: echo $API_KEY > app_store_key.json - name: Sign main binary (macOS) if: runner.os == 'macOS' uses: BloopAI/apple-code-sign-action@v1 with: input_path: target/${{ matrix.target }}/release/server output_path: vibe-kanban p12_file: certificate.p12 p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} sign: true sign_args: "--code-signature-flags=runtime" - name: Package main binary (macOS) if: runner.os == 'macOS' run: zip vibe-kanban.zip vibe-kanban - name: Sign MCP binary (macOS) if: runner.os == 'macOS' uses: BloopAI/apple-code-sign-action@v1 with: input_path: target/${{ matrix.target }}/release/vibe-kanban-mcp output_path: vibe-kanban-mcp p12_file: certificate.p12 p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} sign: true sign_args: "--code-signature-flags=runtime" - name: Package MCP binary (macOS) if: runner.os == 'macOS' run: zip vibe-kanban-mcp.zip vibe-kanban-mcp - name: Sign Review binary (macOS) if: runner.os == 'macOS' uses: BloopAI/apple-code-sign-action@v1 with: input_path: target/${{ matrix.target }}/release/review output_path: vibe-kanban-review p12_file: certificate.p12 p12_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} sign: true sign_args: "--code-signature-flags=runtime" - name: Package Review binary (macOS) if: runner.os == 'macOS' run: zip vibe-kanban-review.zip vibe-kanban-review - name: Notarize signed binaries (macOS) if: runner.os == 'macOS' uses: BloopAI/apple-code-sign-action@main continue-on-error: true with: input_path: | vibe-kanban.zip vibe-kanban-mcp.zip vibe-kanban-review.zip sign: false notarize: true app_store_connect_api_key_json_file: app_store_key.json - name: Prepare signed binaries (macOS) if: runner.os == 'macOS' run: | mkdir -p dist cp vibe-kanban.zip dist/vibe-kanban-${{ matrix.name }}.zip cp vibe-kanban-mcp.zip dist/vibe-kanban-mcp-${{ matrix.name }}.zip cp vibe-kanban-review.zip dist/vibe-kanban-review-${{ matrix.name }}.zip - name: Clean up certificates (macOS) if: runner.os == 'macOS' run: | rm -f certificate.p12 rm -rf private_keys/ - name: Upload binary artifact uses: actions/upload-artifact@v6 with: name: backend-binary-${{ matrix.name }} path: dist/ retention-days: 1 - name: Sweep Cargo target cache shell: bash run: | cargo sweep --maxsize 10GB cargo sweep --time 30 package-npx-cli: needs: [bump-version, build-frontend, build-backend] runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: # NOTE: This matrix must be kept in sync with build-backend job above # GitHub Actions doesn't support YAML anchors, so duplication is unavoidable matrix: include: - target: x86_64-unknown-linux-musl name: linux-x64 binary: vibe-kanban mcp_binary: vibe-kanban-mcp review_binary: vibe-kanban-review - target: x86_64-pc-windows-msvc name: windows-x64 binary: vibe-kanban.exe mcp_binary: vibe-kanban-mcp.exe review_binary: vibe-kanban-review.exe - target: x86_64-apple-darwin name: macos-x64 binary: vibe-kanban mcp_binary: vibe-kanban-mcp review_binary: vibe-kanban-review - target: aarch64-apple-darwin name: macos-arm64 binary: vibe-kanban mcp_binary: vibe-kanban-mcp review_binary: vibe-kanban-review - target: aarch64-pc-windows-msvc name: windows-arm64 binary: vibe-kanban.exe mcp_binary: vibe-kanban-mcp.exe review_binary: vibe-kanban-review.exe - target: aarch64-unknown-linux-musl name: linux-arm64 binary: vibe-kanban mcp_binary: vibe-kanban-mcp review_binary: vibe-kanban-review steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Download frontend artifact uses: actions/download-artifact@v7 with: name: frontend-dist path: packages/local-web/dist/ - name: Download backend binary artifact uses: actions/download-artifact@v7 with: name: backend-binary-${{ matrix.name }} path: dist/ - name: List downloaded artifacts run: | echo "Downloaded backend binaries:" find dist/ - name: Create platform package if: matrix.name != 'macos-arm64' && matrix.name != 'macos-x64' run: | mkdir -p npx-cli/dist/${{ matrix.name }} mkdir vibe-kanban-${{ matrix.name }} mkdir vibe-kanban-mcp-${{ matrix.name }} mkdir vibe-kanban-review-${{ matrix.name }} cp dist/vibe-kanban-${{ matrix.name }}* vibe-kanban-${{ matrix.name }}/${{ matrix.binary }} cp dist/vibe-kanban-mcp-${{ matrix.name }}* vibe-kanban-mcp-${{ matrix.name }}/${{ matrix.mcp_binary }} cp dist/vibe-kanban-review-${{ matrix.name }}* vibe-kanban-review-${{ matrix.name }}/${{ matrix.review_binary }} zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban.zip vibe-kanban-${{ matrix.name }}/${{ matrix.binary }} zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban-mcp.zip vibe-kanban-mcp-${{ matrix.name }}/${{ matrix.mcp_binary }} zip -j npx-cli/dist/${{ matrix.name }}/vibe-kanban-review.zip vibe-kanban-review-${{ matrix.name }}/${{ matrix.review_binary }} - name: Create platform package (macOS) if: matrix.name == 'macos-arm64' || matrix.name == 'macos-x64' run: | mkdir -p npx-cli/dist/${{ matrix.name }} mkdir vibe-kanban-${{ matrix.name }} cp dist/vibe-kanban-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban.zip cp dist/vibe-kanban-mcp-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban-mcp.zip cp dist/vibe-kanban-review-${{ matrix.name }}* npx-cli/dist/${{ matrix.name }}/vibe-kanban-review.zip - name: Upload platform package artifact uses: actions/upload-artifact@v6 with: name: npx-platform-${{ matrix.name }} path: npx-cli/dist/ retention-days: 1 upload-to-r2: needs: [bump-version, package-npx-cli] runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Download all platform packages uses: actions/download-artifact@v7 with: pattern: npx-platform-* path: binaries/ merge-multiple: true - name: List downloaded binaries run: | echo "Downloaded binaries:" find binaries/ - name: Configure AWS CLI for R2 run: | aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }} aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }} aws configure set default.region auto - name: Generate manifest and upload to R2 run: | TAG="${{ needs.bump-version.outputs.new_tag }}" ENDPOINT="${{ secrets.R2_BINARIES_ENDPOINT }}" BUCKET="${{ secrets.R2_BINARIES_BUCKET }}" # Generate version manifest with checksums node -e " const fs = require('fs'); const crypto = require('crypto'); const manifest = { version: '$TAG', platforms: {} }; const platforms = ['linux-x64', 'linux-arm64', 'windows-x64', 'windows-arm64', 'macos-x64', 'macos-arm64']; const binaries = ['vibe-kanban', 'vibe-kanban-mcp', 'vibe-kanban-review']; for (const platform of platforms) { manifest.platforms[platform] = {}; for (const binary of binaries) { const zipPath = \`binaries/\${platform}/\${binary}.zip\`; if (fs.existsSync(zipPath)) { const data = fs.readFileSync(zipPath); manifest.platforms[platform][binary] = { sha256: crypto.createHash('sha256').update(data).digest('hex'), size: data.length }; } } } fs.writeFileSync('version-manifest.json', JSON.stringify(manifest, null, 2)); console.log('Generated manifest:'); console.log(JSON.stringify(manifest, null, 2)); " # Upload binaries (use full tag for path, allows multiple pre-releases to coexist) for platform in linux-x64 linux-arm64 windows-x64 windows-arm64 macos-x64 macos-arm64; do for binary in vibe-kanban vibe-kanban-mcp vibe-kanban-review; do if [ -f "binaries/$platform/$binary.zip" ]; then echo "Uploading binaries/$platform/$binary.zip..." aws s3 cp "binaries/$platform/$binary.zip" \ "s3://$BUCKET/binaries/$TAG/$platform/$binary.zip" \ --endpoint-url "$ENDPOINT" fi done done # Upload version manifest echo "Uploading version manifest..." aws s3 cp version-manifest.json \ "s3://$BUCKET/binaries/$TAG/manifest.json" \ --endpoint-url "$ENDPOINT" --content-type "application/json" # Update global manifest VERSION="${{ needs.bump-version.outputs.new_version }}" echo "Updating global manifest..." echo "{\"latest\": \"$VERSION\"}" | aws s3 cp - \ "s3://$BUCKET/binaries/manifest.json" \ --endpoint-url "$ENDPOINT" --content-type "application/json" - name: Verify upload run: | TAG="${{ needs.bump-version.outputs.new_tag }}" ENDPOINT="${{ secrets.R2_BINARIES_ENDPOINT }}" BUCKET="${{ secrets.R2_BINARIES_BUCKET }}" echo "Listing uploaded files..." aws s3 ls "s3://$BUCKET/binaries/$TAG/" \ --endpoint-url "$ENDPOINT" \ --recursive build-tauri: needs: [bump-version, build-frontend] runs-on: ${{ matrix.os }} defaults: run: shell: bash strategy: matrix: include: - target: aarch64-apple-darwin os: macos-15-xlarge platform: darwin-aarch64 - target: x86_64-apple-darwin os: macos-15-xlarge platform: darwin-x86_64 - target: x86_64-unknown-linux-gnu os: blacksmith-16vcpu-ubuntu-2404 platform: linux-x86_64 - target: aarch64-unknown-linux-gnu os: blacksmith-16vcpu-ubuntu-2404-arm platform: linux-aarch64 - target: x86_64-pc-windows-msvc os: blacksmith-16vcpu-ubuntu-2404 platform: windows-x86_64 - target: aarch64-pc-windows-msvc os: blacksmith-16vcpu-ubuntu-2404 platform: windows-aarch64 env: CARGO_INCREMENTAL: "0" SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" SCCACHE_CACHE_SIZE: "10G" CARGO_HOME: ${{ github.workspace }}/.cargo RUSTUP_HOME: ${{ github.workspace }}/.rustup XWIN_CACHE_DIR: ${{ github.workspace }}/.xwin-cache steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Setup sccache uses: BloopAI/sccache-action@main - name: Cache Rust toolchain uses: actions/cache@v5 with: path: .rustup/toolchains key: rust-toolchain-${{ runner.os }}-${{ matrix.target }}-${{ env.RUST_TOOLCHAIN }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} targets: ${{ matrix.target }} - name: Cache Cargo registry uses: actions/cache@v5 with: path: | .cargo/registry/cache .cargo/registry/index .cargo/git/db .cargo/bin .cargo/.crates.toml .cargo/.crates2.json key: cargo-tauri-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | cargo-tauri-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}- - name: Cache target uses: actions/cache@v5 with: path: target key: tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}-${{ github.sha }} restore-keys: | tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}-${{ github.ref_name }}- tauri-target-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }}-${{ matrix.target }}- - name: Setup cargo-sweep run: cargo install --locked cargo-sweep --version 0.8.0 - name: Setup Node uses: ./.github/actions/setup-node - name: Install Linux dependencies if: runner.os == 'Linux' && contains(matrix.target, 'linux') run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential \ libssl-dev libayatana-appindicator3-dev librsvg2-dev \ libxdo-dev file pkg-config xdg-utils - name: Install Windows cross-compilation dependencies if: runner.os == 'Linux' && contains(matrix.target, 'windows') run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ clang libclang-dev lld llvm nasm cmake ninja-build nsis \ clang-19 clang-tools-19 llvm-19 lld-19 \ meson valac bison gobject-introspection libgirepository1.0-dev \ libglib2.0-dev libgsf-1-dev libgcab-dev libmsi-dev echo "/usr/lib/llvm-19/bin" >> $GITHUB_PATH - name: Build and install wixl if: runner.os == 'Linux' && contains(matrix.target, 'windows') env: RUSTC_WRAPPER: "" run: | cd /tmp curl -sL https://download.gnome.org/sources/msitools/0.103/msitools-0.103.tar.xz | tar xJ cd msitools-0.103 meson setup builddir --prefix=/usr meson compile -C builddir wixl sudo install -m755 builddir/tools/wixl/wixl /usr/local/bin/wixl sudo install -m644 builddir/libmsi/libmsi-1.0.so.0.0.0 /usr/local/lib/ sudo ln -sf libmsi-1.0.so.0.0.0 /usr/local/lib/libmsi-1.0.so.0 sudo ln -sf libmsi-1.0.so.0 /usr/local/lib/libmsi-1.0.so sudo ldconfig sudo mkdir -p /usr/share/wixl-0.103/ext sudo cp -r data/ext/ui /usr/share/wixl-0.103/ext/ cd / && rm -rf /tmp/msitools-0.103 wixl --version - name: Install cargo-xwin if: runner.os == 'Linux' && contains(matrix.target, 'windows') run: cargo install --locked cargo-xwin --version ${{ env.CARGO_XWIN_VERSION }} - name: Cache xwin downloads if: runner.os == 'Linux' && contains(matrix.target, 'windows') uses: actions/cache@v5 with: path: ${{ github.workspace }}/.xwin-cache key: xwin-${{ runner.os }}-${{ matrix.target }}-cargo-xwin-${{ env.CARGO_XWIN_VERSION }} - name: Install dependencies run: pnpm install - name: Install Tauri CLI uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1 with: tool: tauri-cli@2 - name: Download frontend artifact uses: actions/download-artifact@v7 with: name: frontend-dist path: packages/local-web/dist/ - name: Patch tauri.conf.json for CI run: | node -e " const fs = require('fs'); const conf = JSON.parse(fs.readFileSync('crates/tauri-app/tauri.conf.json', 'utf8')); // Inject the real updater endpoint (replaces __TAURI_UPDATE_ENDPOINT__ placeholder) const endpoint = '${{ secrets.R2_BINARIES_PUBLIC_URL }}/binaries/tauri-update/latest.json'; conf.plugins.updater.endpoints = conf.plugins.updater.endpoints.map(e => e === '__TAURI_UPDATE_ENDPOINT__' ? endpoint : e ); fs.writeFileSync('crates/tauri-app/tauri.conf.json', JSON.stringify(conf, null, 2) + '\n'); " - name: Set up Apple notarization key if: runner.os == 'macOS' env: API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} run: | # Write the .p8 key file where Tauri/notarytool can find it mkdir -p ~/.private_keys KEY_PATH="$HOME/.private_keys/AuthKey_${API_KEY_ID}.p8" printf '%s' "$API_PRIVATE_KEY" > "$KEY_PATH" echo "APPLE_API_KEY=$API_KEY_ID" >> $GITHUB_ENV echo "APPLE_API_ISSUER=$API_ISSUER" >> $GITHUB_ENV echo "APPLE_API_KEY_PATH=$KEY_PATH" >> $GITHUB_ENV - name: Build Tauri app run: | if [[ "${{ matrix.target }}" == "aarch64-pc-windows-msvc" ]]; then # ring requires clang on arm64 windows cross-compile chmod +x scripts/ring-cc-wrapper.sh scripts/clang export PATH="${{ github.workspace }}/scripts:$PATH" export RING_CC=/usr/lib/llvm-19/bin/clang export DEFAULT_CC=clang-cl export CC_aarch64_pc_windows_msvc="${{ github.workspace }}/scripts/ring-cc-wrapper.sh" export CARGO_PROFILE_RELEASE_DEBUG=0 fi if [[ "${{ matrix.target }}" == *"windows"* ]]; then cargo tauri build --runner cargo-xwin --target ${{ matrix.target }} --ci else cargo tauri build --target ${{ matrix.target }} --ci fi env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_API_KEY: ${{ env.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }} APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} VK_SHARED_RELAY_API_BASE: ${{ secrets.VK_SHARED_RELAY_API_BASE }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Build MSI with wixl if: contains(matrix.target, 'windows') run: | node scripts/build-tauri-msi.js \ --target ${{ matrix.target }} \ --version ${{ needs.bump-version.outputs.new_version }} - name: Setup Jsign if: contains(matrix.target, 'windows') id: jsign uses: ./.github/actions/setup-jsign - name: Install JRE for Jsign if: contains(matrix.target, 'windows') run: | sudo apt-get update sudo apt-get install -y default-jre-headless - name: Azure CLI login if: contains(matrix.target, 'windows') && env.AZURE_ENDPOINT != '' uses: azure/login@v2 with: creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' allow-no-subscriptions: true env: AZURE_ENDPOINT: ${{ secrets.AZURE_ENDPOINT }} - name: Sign Windows artifacts with Azure Trusted Signing if: contains(matrix.target, 'windows') && env.AZURE_ENDPOINT != '' env: AZURE_ENDPOINT: ${{ secrets.AZURE_ENDPOINT }} AZURE_CODE_SIGNING_NAME: ${{ secrets.AZURE_CODE_SIGNING_NAME }} AZURE_CERT_PROFILE_NAME: ${{ secrets.AZURE_CERT_PROFILE_NAME }} run: | TOKEN=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken -o tsv) # Extract hostname from endpoint URL KEYSTORE=$(echo "$AZURE_ENDPOINT" | sed 's|https\?://||;s|/.*||') # Sign all exe and msi files in the bundle directory find target/${{ matrix.target }}/release/bundle -type f \( -name "*.exe" -o -name "*.msi" \) | while read file; do echo "Signing: $file" java -jar "${{ steps.jsign.outputs.jar-path }}" \ --storetype TRUSTEDSIGNING \ --keystore "$KEYSTORE" \ --storepass "$TOKEN" \ --alias "${AZURE_CODE_SIGNING_NAME}/${AZURE_CERT_PROFILE_NAME}" \ --tsaurl http://timestamp.acs.microsoft.com \ --tsmode RFC3161 \ "$file" done - name: Collect updater artifacts and installers run: | mkdir -p tauri-artifacts/${{ matrix.platform }} # Collect updater artifacts (.sig files and their corresponding bundles) find target/${{ matrix.target }}/release/bundle -name "*.sig" | while read sig; do artifact="${sig%.sig}" if [ -f "$artifact" ]; then cp "$artifact" "tauri-artifacts/${{ matrix.platform }}/" cp "$sig" "tauri-artifacts/${{ matrix.platform }}/" echo "Collected updater artifact: $(basename $artifact)" fi done # Collect installer files for GitHub Release (DMG, AppImage, deb, msi, NSIS exe) find target/${{ matrix.target }}/release/bundle \ \( -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.msi" -o -name "*-setup.exe" \) | while read f; do cp "$f" "tauri-artifacts/${{ matrix.platform }}/" echo "Collected installer: $(basename $f)" done echo "All artifacts for ${{ matrix.platform }}:" ls -la tauri-artifacts/${{ matrix.platform }}/ - name: Clean up signing keys if: always() && runner.os == 'macOS' run: rm -rf ~/.private_keys - name: Upload Tauri artifacts uses: actions/upload-artifact@v6 with: name: tauri-artifacts-${{ matrix.platform }} path: tauri-artifacts/ retention-days: 1 - name: Sweep Cargo target cache if: always() run: | cargo sweep --maxsize 10GB cargo sweep --time 30 upload-tauri-update: needs: [bump-version, build-tauri] runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Setup Node uses: ./.github/actions/setup-node - name: Download all Tauri artifacts uses: actions/download-artifact@v7 with: pattern: tauri-artifacts-* path: tauri-artifacts/ merge-multiple: true - name: List Tauri artifacts run: find tauri-artifacts/ - name: Generate updater manifest run: | node scripts/generate-tauri-update-json.js \ --version "${{ needs.bump-version.outputs.new_version }}" \ --notes "Release ${{ needs.bump-version.outputs.new_tag }}" \ --artifacts-dir ./tauri-artifacts \ --download-base "${{ secrets.R2_BINARIES_PUBLIC_URL }}/binaries/${{ needs.bump-version.outputs.new_tag }}/tauri" \ --output latest.json echo "Generated latest.json:" cat latest.json - name: Generate desktop manifest for NPX CLI run: | node scripts/generate-desktop-manifest.js \ --version "${{ needs.bump-version.outputs.new_version }}" \ --artifacts-dir ./tauri-artifacts \ --output desktop-manifest.json echo "Generated desktop-manifest.json:" cat desktop-manifest.json - name: Configure AWS CLI for R2 run: | aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }} aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }} aws configure set default.region auto - name: Upload Tauri artifacts and update manifest to R2 run: | TAG="${{ needs.bump-version.outputs.new_tag }}" ENDPOINT="${{ secrets.R2_BINARIES_ENDPOINT }}" BUCKET="${{ secrets.R2_BINARIES_BUCKET }}" # Upload individual platform artifacts for platform in darwin-aarch64 darwin-x86_64 linux-x86_64 linux-aarch64 windows-x86_64 windows-aarch64; do if [ -d "tauri-artifacts/$platform" ]; then for file in tauri-artifacts/$platform/*; do [ -f "$file" ] || continue # Skip .sig files from artifact upload (signatures are in latest.json) [[ "$file" == *.sig ]] && continue filename=$(basename "$file") echo "Uploading $filename for $platform..." aws s3 cp "$file" \ "s3://$BUCKET/binaries/$TAG/tauri/$platform/$filename" \ --endpoint-url "$ENDPOINT" done fi done # Upload latest.json alongside the tag artifacts (NOT to the fixed # update endpoint). The fixed endpoint is only updated when a # pre-release is promoted to a full release (see publish.yml). echo "Uploading updater manifest for tag..." aws s3 cp latest.json \ "s3://$BUCKET/binaries/$TAG/tauri/latest.json" \ --endpoint-url "$ENDPOINT" --content-type "application/json" echo "Update manifest uploaded to: binaries/$TAG/tauri/latest.json" # Upload desktop manifest for NPX CLI auto-install echo "Uploading desktop manifest..." aws s3 cp desktop-manifest.json \ "s3://$BUCKET/binaries/$TAG/tauri/desktop-manifest.json" \ --endpoint-url "$ENDPOINT" --content-type "application/json" echo "Desktop manifest uploaded to: binaries/$TAG/tauri/desktop-manifest.json" create-prerelease: needs: [bump-version, build-frontend, upload-to-r2, upload-tauri-update] runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v6 with: ref: ${{ needs.bump-version.outputs.new_tag }} - name: Download frontend artifact uses: actions/download-artifact@v7 with: name: frontend-dist path: packages/local-web/dist/ - name: Download Tauri artifacts for release uses: actions/download-artifact@v7 with: pattern: tauri-artifacts-* path: tauri-release/ merge-multiple: true - name: Collect Tauri installers for release run: | mkdir -p tauri-installers find tauri-release \( -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.msi" -o -name "*-setup.exe" \) \ -exec cp {} tauri-installers/ \; echo "Tauri installers for release:" ls -la tauri-installers/ 2>/dev/null || echo "No installers found" - name: List downloaded artifacts run: | echo "Web dist:" find packages/local-web/dist - name: Zip frontend run: | mkdir vibe-kanban-${{ needs.bump-version.outputs.new_tag }} mv packages/local-web/dist vibe-kanban-${{ needs.bump-version.outputs.new_tag }} zip -r vibe-kanban-${{ needs.bump-version.outputs.new_tag }}.zip vibe-kanban-${{ needs.bump-version.outputs.new_tag }} - name: Setup Node for npm pack uses: ./.github/actions/setup-node - name: Install npx-cli dependencies run: | cd npx-cli npm ci - name: Build npx-cli TypeScript, inject secrets, and Pack run: | cd npx-cli npm run build # Replace placeholders in the bundled output sed -i "s|__R2_PUBLIC_URL__|${{ secrets.R2_BINARIES_PUBLIC_URL }}|g" bin/cli.js sed -i "s|__BINARY_TAG__|${{ needs.bump-version.outputs.new_tag }}|g" bin/cli.js npm pack - name: Create GitHub Pre-Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.bump-version.outputs.new_tag }} name: Pre-release ${{ needs.bump-version.outputs.new_tag }} prerelease: true generate_release_notes: true files: | vibe-kanban-${{ needs.bump-version.outputs.new_tag }}.zip npx-cli/vibe-kanban-*.tgz tauri-installers/* ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish to npm on: release: types: [released] workflow_dispatch: inputs: tag_name: description: "Release tag (e.g., v1.2.3)" required: true release_id: description: "GitHub release ID" required: true concurrency: group: publish cancel-in-progress: true permissions: contents: write packages: write id-token: write # Required for OIDC trusted publishing env: NODE_VERSION: 22 PNPM_VERSION: 10.13.1 jobs: publish: runs-on: ubuntu-latest # Only run for main app releases (not remote-v* tags) that were converted from pre-release if: github.event.release.prerelease == false && !startsWith(github.event.release.tag_name, 'remote-') steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.release.tag_name || inputs.tag_name }} - name: Setup Node uses: ./.github/actions/setup-node - name: Upgrade npm for OIDC support run: npm install -g npm@latest - name: Download release assets uses: actions/github-script@v8 env: RELEASE_ID: ${{ inputs.release_id }} with: script: | const fs = require('fs'); const path = require('path'); const releaseId = context.payload.release?.id || process.env.RELEASE_ID; console.log("releaseId:", releaseId); if (!releaseId) { core.setFailed('No release ID found.'); return; } const release = await github.rest.repos.getRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: releaseId }); // Find the .tgz file const tgzAsset = release.data.assets.find(asset => asset.name.endsWith('.tgz')); if (!tgzAsset) { core.setFailed('No .tgz file found in release assets'); return; } // Download the asset const response = await github.rest.repos.getReleaseAsset({ owner: context.repo.owner, repo: context.repo.repo, asset_id: tgzAsset.id, headers: { Accept: 'application/octet-stream' } }); // Save to npx-cli directory const filePath = path.join('npx-cli', tgzAsset.name); fs.writeFileSync(filePath, Buffer.from(response.data)); console.log(`Downloaded ${tgzAsset.name} to ${filePath}`); // Set output for next step core.setOutput('package-file', filePath); core.setOutput('package-name', tgzAsset.name); - name: Verify package integrity id: verify run: | cd npx-cli # List files to confirm download ls -la *.tgz # Verify the package can be read npm pack --dry-run || echo "Note: This is expected to show differences since we're using the pre-built package" # Extract package name from the downloaded file PACKAGE_FILE=$(ls *.tgz | head -n1) echo "package-file=$PACKAGE_FILE" >> $GITHUB_OUTPUT - name: Publish to npm run: | cd npx-cli # Publish the exact same package that was tested PACKAGE_FILE="${{ steps.verify.outputs.package-file }}" echo "Publishing $PACKAGE_FILE to npm..." npm publish "$PACKAGE_FILE" --provenance --access public echo "✅ Successfully published to npm!" - name: Update release description uses: actions/github-script@v8 env: RELEASE_ID: ${{ inputs.release_id }} with: script: | const releaseId = context.payload.release?.id || process.env.RELEASE_ID;; // Fetch the release to get the current body const release = await github.rest.repos.getRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: releaseId }); const currentBody = release.data.body || ''; await github.rest.repos.updateRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: releaseId, body: currentBody + '\n\n✅ **Published to npm registry**' }); # Promote the tag-specific Tauri update manifest to the fixed endpoint # so existing desktop app users receive the update notification. promote-tauri-update: runs-on: ubuntu-latest if: github.event.release.prerelease == false && !startsWith(github.event.release.tag_name, 'remote-') steps: - name: Configure AWS CLI for R2 run: | aws configure set aws_access_key_id ${{ secrets.R2_BINARIES_ACCESS_KEY_ID }} aws configure set aws_secret_access_key ${{ secrets.R2_BINARIES_SECRET_ACCESS_KEY }} aws configure set default.region auto - name: Copy update manifest to live endpoint run: | TAG="${{ github.event.release.tag_name || inputs.tag_name }}" ENDPOINT="${{ secrets.R2_BINARIES_ENDPOINT }}" BUCKET="${{ secrets.R2_BINARIES_BUCKET }}" echo "Promoting update manifest for $TAG to live endpoint..." aws s3 cp \ "s3://$BUCKET/binaries/$TAG/tauri/latest.json" \ "s3://$BUCKET/binaries/tauri-update/latest.json" \ --endpoint-url "$ENDPOINT" --content-type "application/json" echo "Update manifest promoted: binaries/tauri-update/latest.json" ================================================ FILE: .github/workflows/relay-deploy-dev.yml ================================================ name: Relay Deploy Dev on: push: branches: - main paths: - crates/relay-tunnel/** workflow_dispatch: jobs: run-relay-deploy: name: Deploy Relay Dev runs-on: ubuntu-latest permissions: contents: read steps: - name: Dispatch dev relay deployment workflow uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} repository: BloopAI/vibe-kanban-remote-deployment event-type: vibe-kanban-relay-deploy-dev client-payload: | { "ref": "${{ github.ref_name }}", "sha": "${{ github.sha }}" } ================================================ FILE: .github/workflows/relay-deploy-prod.yml ================================================ name: Deploy Relay Prod on: workflow_dispatch: inputs: version_type: description: "Version bump type" required: true default: "patch" type: choice options: - patch - minor - major concurrency: group: relay-deploy-${{ github.ref_name }} cancel-in-progress: true permissions: contents: write env: RUST_TOOLCHAIN: nightly-2025-12-04 jobs: release-and-deploy: runs-on: ubuntu-latest steps: - name: Cache cargo-edit uses: actions/cache@v5 id: cache-cargo-edit with: path: ~/.cargo/bin/cargo-set-version key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }} - name: Install cargo-edit if: steps.cache-cargo-edit.outputs.cache-hit != 'true' run: cargo install cargo-edit - uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ssh-key: ${{ secrets.DEPLOY_KEY }} - name: Determine and update version id: version run: | # Get current version from crates/relay-tunnel/Cargo.toml current_version=$(grep '^version = ' crates/relay-tunnel/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') echo "Current relay-tunnel version: $current_version" # Parse version components IFS='.' read -r major minor patch <<< "$current_version" # Bump based on type case "${{ github.event.inputs.version_type }}" in major) major=$((major + 1)) minor=0 patch=0 ;; minor) minor=$((minor + 1)) patch=0 ;; patch) patch=$((patch + 1)) ;; esac new_version="${major}.${minor}.${patch}" new_tag="relay-v${new_version}" # Update version in crates/relay-tunnel/Cargo.toml cd crates/relay-tunnel cargo set-version "$new_version" cargo update relay-tunnel cd ../.. echo "New version: $new_version" echo "New tag: $new_tag" echo "new_version=$new_version" >> $GITHUB_OUTPUT echo "new_tag=$new_tag" >> $GITHUB_OUTPUT echo "new_ref=$new_tag" >> $GITHUB_OUTPUT - name: Commit changes and create tag run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add crates/relay-tunnel/Cargo.toml crates/relay-tunnel/Cargo.lock git commit -m "chore: bump relay-tunnel version to ${{ steps.version.outputs.new_version }}" git tag -a ${{ steps.version.outputs.new_tag }} -m "Relay release ${{ steps.version.outputs.new_tag }}" git push git push --tags - name: Dispatch relay deployment workflow uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} repository: BloopAI/vibe-kanban-remote-deployment event-type: vibe-kanban-relay-deploy-prod client-payload: | { "ref": "${{ steps.version.outputs.new_ref }}", "sha": "${{ github.sha }}", "version": "${{ steps.version.outputs.new_version }}" } ================================================ FILE: .github/workflows/relay-release.yml ================================================ name: Create Relay Release on: repository_dispatch: types: [relay-deploy-success] jobs: create-release: runs-on: ubuntu-latest permissions: contents: write steps: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.client_payload.tag }} name: ${{ github.event.client_payload.tag }} generate_release_notes: true body: | ## Relay Tunnel Service Release Deployed to ${{ github.event.client_payload.environment }} ================================================ FILE: .github/workflows/remote-deploy-dev.yml ================================================ name: Remote Deploy Dev on: push: branches: - main paths: - crates/remote/** - packages/remote-web/** workflow_dispatch: jobs: run-remote-deploy: name: Deploy Remote Dev runs-on: ubuntu-latest permissions: contents: read steps: - name: Dispatch dev remote deployment workflow uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} repository: BloopAI/vibe-kanban-remote-deployment event-type: vibe-kanban-remote-deploy-dev client-payload: | { "ref": "${{ github.ref_name }}", "sha": "${{ github.sha }}" } ================================================ FILE: .github/workflows/remote-deploy-prod.yml ================================================ name: Deploy Remote Prod on: workflow_dispatch: inputs: version_type: description: "Version bump type" required: true default: "patch" type: choice options: - patch - minor - major concurrency: group: remote-deploy-${{ github.ref_name }} cancel-in-progress: true permissions: contents: write env: RUST_TOOLCHAIN: nightly-2025-12-04 jobs: release-and-deploy: runs-on: ubuntu-latest steps: - name: Cache cargo-edit uses: actions/cache@v5 id: cache-cargo-edit with: path: ~/.cargo/bin/cargo-set-version key: cargo-edit-${{ runner.os }}-${{ env.RUST_TOOLCHAIN }} - name: Install cargo-edit if: steps.cache-cargo-edit.outputs.cache-hit != 'true' run: cargo install cargo-edit - uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} ssh-key: ${{ secrets.DEPLOY_KEY }} - name: Setup SSH Agent for private dependencies uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }} - name: Determine and update version id: version run: | # Get current version from crates/remote/Cargo.toml current_version=$(grep '^version = ' crates/remote/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') echo "Current remote version: $current_version" # Parse version components IFS='.' read -r major minor patch <<< "$current_version" # Bump based on type case "${{ github.event.inputs.version_type }}" in major) major=$((major + 1)) minor=0 patch=0 ;; minor) minor=$((minor + 1)) patch=0 ;; patch) patch=$((patch + 1)) ;; esac new_version="${major}.${minor}.${patch}" new_tag="remote-v${new_version}" # Update version in crates/remote/Cargo.toml cd crates/remote cargo set-version "$new_version" cargo update remote cd ../.. echo "New version: $new_version" echo "New tag: $new_tag" echo "new_version=$new_version" >> $GITHUB_OUTPUT echo "new_tag=$new_tag" >> $GITHUB_OUTPUT echo "new_ref=$new_tag" >> $GITHUB_OUTPUT - name: Stop SSH agent run: ssh-agent -k - name: Commit changes and create tag run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add crates/remote/Cargo.toml crates/remote/Cargo.lock git commit -m "chore: bump remote version to ${{ steps.version.outputs.new_version }}" git tag -a ${{ steps.version.outputs.new_tag }} -m "Remote release ${{ steps.version.outputs.new_tag }}" git push git push --tags - name: Dispatch remote deployment workflow uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} repository: BloopAI/vibe-kanban-remote-deployment event-type: vibe-kanban-remote-deploy-prod client-payload: | { "ref": "${{ steps.version.outputs.new_ref }}", "sha": "${{ github.sha }}", "version": "${{ steps.version.outputs.new_version }}" } ================================================ FILE: .github/workflows/remote-release.yml ================================================ name: Create Remote Release on: repository_dispatch: types: [remote-deploy-success] jobs: create-release: runs-on: ubuntu-latest permissions: contents: write steps: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.client_payload.tag }} name: ${{ github.event.client_payload.tag }} generate_release_notes: true body: | ## Remote Service Release ✅ Deployed to ${{ github.event.client_payload.environment }} ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: pull_request: branches: - main paths-ignore: - .github/workflows/** - '!.github/workflows/test.yml' push: branches: - main workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: CARGO_TERM_COLOR: always NODE_VERSION: 22 PNPM_VERSION: 10.13.1 RUST_TOOLCHAIN: nightly-2025-12-04 RUNNER_LABEL: &runner_label blacksmith-16vcpu-ubuntu-2404 jobs: changes: runs-on: *runner_label if: github.event_name == 'pull_request' permissions: contents: read pull-requests: read outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} backend-remote: ${{ steps.filter.outputs['backend-remote'] }} tauri: ${{ steps.filter.outputs.tauri }} steps: - uses: actions/checkout@v6 - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 id: filter with: filters: | frontend: - 'packages/local-web/**' - 'packages/web-core/**' - 'packages/remote-web/**' - 'packages/ui/**' - 'shared/**' - 'scripts/check-i18n.sh' - 'scripts/check-unused-i18n-keys.mjs' - 'scripts/check-legacy-frontend-paths.sh' - 'scripts/legacy-frontend-paths-allowlist.txt' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'package.json' - '.npmrc' - '.github/workflows/test.yml' - '.github/actions/**' backend: - 'crates/api-types/**' - 'crates/db/**' - 'crates/deployment/**' - 'crates/executors/**' - 'crates/git/**' - 'crates/local-deployment/**' - 'crates/mcp/**' - 'crates/review/**' - 'crates/server/**' - 'crates/services/**' - 'crates/utils/**' - 'Cargo.toml' - 'Cargo.lock' - 'shared/**' - 'scripts/prepare-db.js' - 'pnpm-lock.yaml' - 'package.json' - 'rustfmt.toml' - 'rust-toolchain.toml' - '.cargo/**' - '.github/workflows/test.yml' - '.github/actions/**' backend-remote: - 'crates/remote/**' - 'crates/relay-tunnel/**' - 'crates/api-types/**' - 'crates/utils/**' - 'Cargo.toml' - 'Cargo.lock' - 'shared/**' - 'rustfmt.toml' - 'rust-toolchain.toml' - '.cargo/**' - 'pnpm-lock.yaml' - 'package.json' - '.github/workflows/test.yml' - '.github/actions/**' tauri: - 'crates/tauri-app/**' - 'crates/api-types/**' - 'crates/db/**' - 'crates/deployment/**' - 'crates/executors/**' - 'crates/git/**' - 'crates/local-deployment/**' - 'crates/mcp/**' - 'crates/review/**' - 'crates/server/**' - 'crates/services/**' - 'crates/utils/**' - 'Cargo.toml' - 'Cargo.lock' - 'rustfmt.toml' - 'rust-toolchain.toml' - '.cargo/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - '.npmrc' - '.github/workflows/test.yml' - '.github/actions/**' frontend-checks: needs: changes if: always() && (needs.changes.outputs.frontend == 'true' || needs.changes.result == 'skipped') runs-on: *runner_label steps: - uses: actions/checkout@v6 - name: Setup Node uses: ./.github/actions/setup-node - name: Install dependencies run: pnpm install - name: Run frontend checks env: NODE_OPTIONS: --max-old-space-size=8192 run: | npx concurrently \ --kill-others-on-fail \ --names "local:lint,local:fmt,local:build,remote:fmt,remote:build,ui:check,ui:lint,ui:fmt,core:check,core:fmt,i18n,i18n:unused,legacy" \ --timings \ "cd packages/local-web && npm run lint" \ "cd packages/local-web && npm run format:check" \ "cd packages/local-web && npm run build" \ "cd packages/remote-web && npm run format:check" \ "cd packages/remote-web && npm run build" \ "cd packages/ui && npm run check" \ "cd packages/ui && npm run lint" \ "cd packages/ui && npm run format:check" \ "cd packages/web-core && npm run check" \ "cd packages/web-core && npm run format:check" \ "GITHUB_BASE_REF=${{ github.base_ref || 'main' }} ./scripts/check-i18n.sh" \ "node scripts/check-unused-i18n-keys.mjs" \ "./scripts/check-legacy-frontend-paths.sh" backend-schema-checks: needs: changes if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped') runs-on: *runner_label env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 - name: Setup Rust uses: ./.github/actions/cargo-checks-common-setup with: toolchain: ${{ env.RUST_TOOLCHAIN }} cache-key: backend-schema-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }} setup-node: 'true' setup-sqlx-cli: 'true' - name: Check generated types run: npm run generate-types:check - name: Sqlx checks run: npm run prepare-db:check backend-remote-checks: needs: changes if: >- always() && (needs.changes.outputs['backend-remote'] == 'true' || needs.changes.result == 'skipped') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: *runner_label env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 - name: Setup Rust uses: ./.github/actions/cargo-checks-common-setup with: toolchain: ${{ env.RUST_TOOLCHAIN }} components: rustfmt, clippy cache-key: backend-remote-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }} setup-node: 'true' setup-sqlx-cli: 'true' - name: Setup SSH Agent for private dependencies uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.VK_PRIVATE_DEPLOY_KEY }} - name: Check remote Cargo.lock is consistent run: | cargo metadata --locked --format-version 1 --manifest-path crates/remote/Cargo.toml > /dev/null cargo metadata --locked --format-version 1 --manifest-path crates/relay-tunnel/Cargo.toml > /dev/null - name: Check formatting run: | cargo fmt --all --manifest-path crates/remote/Cargo.toml -- --check cargo fmt --all --manifest-path crates/relay-tunnel/Cargo.toml -- --check - name: Run Clippy run: | cargo clippy --all-targets --manifest-path crates/remote/Cargo.toml -- -D warnings cargo clippy --all-targets --manifest-path crates/relay-tunnel/Cargo.toml -- -D warnings - name: Check generated types run: npm run remote:generate-types:check - name: Sqlx checks run: npm run remote:prepare-db:check backend-clippy: needs: changes if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped') runs-on: *runner_label env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 - name: Setup Rust uses: ./.github/actions/cargo-checks-common-setup with: toolchain: ${{ env.RUST_TOOLCHAIN }} components: rustfmt, clippy cache-key: backend-clippy-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }} - name: Run Clippy and fmt checks run: | set -x cargo fmt --all -- --check & pid_fmt=$! cargo clippy --workspace --all-targets --exclude vibe-kanban-tauri -- -D warnings wait $pid_fmt backend-test: needs: changes if: always() && (needs.changes.outputs.backend == 'true' || needs.changes.result == 'skipped') runs-on: *runner_label env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 - name: Setup Rust uses: ./.github/actions/cargo-checks-common-setup with: toolchain: ${{ env.RUST_TOOLCHAIN }} cache-key: backend-test-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }} - name: Install cargo-nextest uses: taiki-e/install-action@nextest - name: Run Cargo tests run: cargo nextest run --workspace --exclude vibe-kanban-tauri tauri-checks: needs: changes if: always() && (needs.changes.outputs.tauri == 'true' || needs.changes.result == 'skipped') runs-on: *runner_label env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 - name: Setup Rust uses: ./.github/actions/cargo-checks-common-setup with: toolchain: ${{ env.RUST_TOOLCHAIN }} components: rustfmt, clippy cache-key: tauri-checks-${{ runner.os }}-${{ runner.arch }}-${{ env.RUNNER_LABEL }} - name: Install Linux dependencies run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential \ libssl-dev libayatana-appindicator3-dev librsvg2-dev \ libxdo-dev file pkg-config xdg-utils - name: Run Tauri fmt, clippy, and compile checks run: | cargo fmt --all --manifest-path crates/tauri-app/Cargo.toml -- --check cargo clippy --all-targets --manifest-path crates/tauri-app/Cargo.toml -- -D warnings cargo check -p vibe-kanban-tauri ================================================ FILE: .gitignore ================================================ # Rust target/ **/*.rs.bk # Node.js node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # Build outputs /dist /build bindings/ # Environment variables .env .env.remote .env.local .env.development.local .env.test.local .env.production.local .env.production # IDE .vscode/ .idea/ *.swp *.swo *~ # OS .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Runtime data pids *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ *.lcov # nyc test coverage .nyc_output # ESLint cache .eslintcache # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Storybook build outputs .out .storybook-out .env packages/**/dist build-npm-package-codesign.sh npx-cli/bin/ npx-cli/dist npx-cli/vibe-kanban-* vibe-kanban-*.tgz # Development ports file .dev-ports.json dev_assets /packages/web/.env.sentry-build-plugin .ssh vibe-kanban-cloud/ .jj .sisyphus/ # Vite temp files *.timestamp-*.mjs # Mobile testing (per-developer, do not commit) *.ts.net.crt *.ts.net.key Caddyfile Caddyfile.dev ================================================ FILE: .npmrc ================================================ engine-strict=true ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Module Organization - `crates/`: Rust workspace crates — `server` (API + bins), `db` (SQLx models/migrations), `executors`, `services`, `utils`, `git` (Git operations), `api-types` (shared API types for local + remote), `review` (PR review tool), `deployment`, `local-deployment`, `remote`. - `packages/local-web/`: Local React + TypeScript app entrypoint (Vite, Tailwind). Shell source in `packages/local-web/src`. - `packages/remote-web/`: Remote deployment frontend entrypoint. - `packages/web-core/`: Shared React + TypeScript frontend library used by local + remote web (`packages/web-core/src`). - `shared/`: Generated TypeScript types (`shared/types.ts`, `shared/remote-types.ts`) and agent tool schemas (`shared/schemas/`). Do not edit generated files directly. - `assets/`, `dev_assets_seed/`, `dev_assets/`: Packaged and local dev assets. - `npx-cli/`: Files published to the npm CLI package. - `scripts/`: Dev helpers (ports, DB preparation). - `docs/`: Documentation files. ### Crate-specific guides - [`crates/remote/AGENTS.md`](crates/remote/AGENTS.md) — Remote server architecture, ElectricSQL integration, mutation patterns, environment variables. - [`docs/AGENTS.md`](docs/AGENTS.md) — Mintlify documentation writing guidelines and component reference. - [`packages/local-web/AGENTS.md`](packages/local-web/AGENTS.md) — Web app design system styling guidelines. ## Managing Shared Types Between Rust and TypeScript ts-rs allows you to derive TypeScript types from Rust structs/enums. By annotating your Rust types with #[derive(TS)] and related macros, ts-rs will generate .ts declaration files for those types. When making changes to the types, you can regenerate them using `pnpm run generate-types` Do not manually edit shared/types.ts, instead edit crates/server/src/bin/generate_types.rs For remote/cloud types, regenerate using `pnpm run remote:generate-types` Do not manually edit shared/remote-types.ts, instead edit crates/remote/src/bin/remote-generate-types.rs (see crates/remote/AGENTS.md for details). ## Build, Test, and Development Commands - Install: `pnpm i` - Run dev (web app + backend with ports auto-assigned): `pnpm run dev` - Backend (watch): `pnpm run backend:dev:watch` - Web app (dev): `pnpm run local-web:dev` - Type checks: `pnpm run check` (frontend + all backend Rust workspaces) and `pnpm run backend:check` (all backend Rust workspaces, including `crates/remote`) - Rust tests: `cargo test --workspace` - Generate TS types from Rust: `pnpm run generate-types` (or `generate-types:check` in CI) - Prepare SQLx (offline): `pnpm run prepare-db` - Prepare SQLx (remote package, postgres): `pnpm run remote:prepare-db` - Local NPX build: `pnpm run build:npx` then `pnpm pack` in `npx-cli/` - Format code: `pnpm run format` (runs `cargo fmt` for all backend Rust workspaces + web-core/web Prettier) - Lint: `pnpm run lint` (runs web/ui ESLint + `cargo clippy` for all backend Rust workspaces) ## Before Completing a Task - Run `pnpm run format` to format all Rust workspaces and web code. ## Coding Style & Naming Conventions - Rust: `rustfmt` enforced (`rustfmt.toml`); group imports by crate; snake_case modules, PascalCase types. - TypeScript/React: ESLint + Prettier (2 spaces, single quotes, 80 cols). PascalCase components, camelCase vars/functions, kebab-case file names where practical. - Keep functions small, add `Debug`/`Serialize`/`Deserialize` where useful. ## Testing Guidelines - Rust: prefer unit tests alongside code (`#[cfg(test)]`), run `cargo test --workspace`. Add tests for new logic and edge cases. - Web app: ensure `pnpm run check` and `pnpm run lint` pass. If adding runtime logic, include lightweight tests (e.g., Vitest) in the same directory. ## Security & Config Tips - Use `.env` for local overrides; never commit secrets. Key envs: `FRONTEND_PORT`, `BACKEND_PORT`, `HOST` - Dev ports and assets are managed by `scripts/setup-dev-environment.js`. ================================================ FILE: CODE-OF-CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at maintainers@bloop.ai through e-mail, with an appropriate subject line. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ### Attribution This Code of Conduct is adapted from the [Next.js project][nextjs-coc] The original text is from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [nextjs-coc]: https://raw.githubusercontent.com/vercel/next.js/canary/CODE_OF_CONDUCT.md [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: CONTRIBUTORS.md ================================================ # Contributing & Change Control ## Change Control Policy All changes to production code are governed by formal change control procedures. These procedures ensure that modifications are reviewed, approved, and deployed in a controlled manner. ## Code Review Requirements A maintainer must review pull requests before they are merged into any production branch. No code changes shall be merged without explicit approval from a qualified reviewer. ## Pull Request Process 1. Create a feature or fix branch from the base branch. 2. Make changes and open a pull request. 3. Obtain the required review and approval from a maintainer. 4. All required CI checks must pass before merging. 5. Merge only after approval has been granted and CI checks have passed. ## Separation of Duties Development, testing, and deployment of changes shall not be performed by a single individual without approval and oversight. All significant changes require independent review to ensure correctness, security, and alignment with project standards. ## Coding Practices Contributors are expected to follow the project's coding standards throughout the development cycle. These standards cover code quality, style consistency, and security. ### Style & Formatting - **Rust**: Code must be formatted with `rustfmt` (config in `rustfmt.toml`). Use `snake_case` for modules and functions, `PascalCase` for types. Group imports by crate. - **TypeScript/React**: Code must pass ESLint and Prettier (2 spaces, single quotes, 80-column width). Use `PascalCase` for components, `camelCase` for variables and functions, and `kebab-case` for file names. - Run `pnpm run format` before submitting a pull request. - Run `pnpm run lint` to verify there are no linting errors. ### Code Quality - Keep functions small and focused on a single responsibility. - Write clear, self-documenting code. Add comments only where the logic is not self-evident. - Do not introduce unnecessary abstractions or over-engineer solutions. - Do not manually edit generated files (e.g., `shared/types.ts`). Modify the source and regenerate. ### Testing - **Rust**: Add unit tests alongside code using `#[cfg(test)]`. Run `cargo test --workspace` to verify. - **TypeScript**: Ensure `pnpm run check` and `pnpm run lint` pass. Include lightweight tests (e.g., Vitest) for new runtime logic. - All CI checks must pass before a pull request can be merged. ### Security - Never commit secrets, credentials, or API keys. Use `.env` for local configuration. - Be mindful of common vulnerabilities (injection, XSS, insecure deserialization) when writing code that handles user input or external data. - Report security issues privately to the maintainers rather than opening a public issue. ### Commit Messages - Use clear, descriptive commit messages that explain the _why_ behind a change. - Prefix with a conventional type where appropriate (e.g., `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`). - Keep the subject line under 72 characters. Use the body for additional context if needed. ## Scope These procedures apply to all production branches in this repository. ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "3" members = [ "crates/api-types", "crates/server", "crates/trusted-key-auth", "crates/mcp", "crates/db", "crates/executors", "crates/services", "crates/worktree-manager", "crates/workspace-manager", "crates/relay-control", "crates/server-info", "crates/utils", "crates/git", "crates/git-host", "crates/local-deployment", "crates/deployment", "crates/review", "crates/tauri-app", ] exclude = ["crates/remote", "crates/relay-tunnel"] [workspace.dependencies] tokio = { version = "1.0", features = ["full"] } axum = { version = "0.8.4", features = ["macros", "multipart", "ws"] } tower-http = { version = "0.5", features = ["cors", "request-id", "trace", "fs", "validate-request", "compression-gzip", "compression-br"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } anyhow = "1.0" git2 = { version = "0.20.3", default-features = false } reqwest = { version = "0.13", default-features = false, features = ["json", "query", "stream", "rustls"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } thiserror = "2.0.12" tracing = "0.1.43" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } ts-rs = { git = "https://github.com/xazukx/ts-rs.git", branch = "use-ts-enum", features = ["uuid-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] } schemars = { version = "1.0.4", features = ["derive", "chrono04", "uuid1", "preserve_order"] } serde_with = "3" async-trait = "0.1" aws-lc-sys = "=0.37.0" aws-lc-rs = "=1.16.0" [profile.release] debug = 1 split-debuginfo = "packed" strip = true ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1.6 FROM node:24-alpine AS fe-builder ARG POSTHOG_API_KEY="" ARG POSTHOG_API_ENDPOINT="" WORKDIR /app ENV PNPM_HOME=/pnpm ENV PATH=${PNPM_HOME}:${PATH} ENV VITE_PUBLIC_POSTHOG_KEY=${POSTHOG_API_KEY} ENV VITE_PUBLIC_POSTHOG_HOST=${POSTHOG_API_ENDPOINT} ENV NODE_OPTIONS=--max-old-space-size=4096 RUN corepack enable RUN pnpm config set store-dir /pnpm/store COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages/local-web/package.json packages/local-web/package.json COPY packages/ui/package.json packages/ui/package.json COPY packages/web-core/package.json packages/web-core/package.json RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile COPY packages/local-web/ packages/local-web/ COPY packages/public/ packages/public/ COPY packages/ui/ packages/ui/ COPY packages/web-core/ packages/web-core/ COPY shared/ shared/ RUN pnpm -C packages/local-web build FROM rust:1.93-slim-bookworm AS builder ARG POSTHOG_API_KEY="" ARG POSTHOG_API_ENDPOINT="" ARG SENTRY_DSN="" ARG VK_SHARED_API_BASE="" ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse ENV CARGO_NET_GIT_FETCH_WITH_CLI=true ENV CARGO_TARGET_DIR=/app/target ENV POSTHOG_API_KEY=${POSTHOG_API_KEY} ENV POSTHOG_API_ENDPOINT=${POSTHOG_API_ENDPOINT} ENV SENTRY_DSN=${SENTRY_DSN} ENV VK_SHARED_API_BASE=${VK_SHARED_API_BASE} WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ ca-certificates \ git \ libclang-dev \ libssl-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* COPY rust-toolchain.toml ./ RUN cargo --version >/dev/null COPY Cargo.toml Cargo.lock ./ COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml COPY crates/db/Cargo.toml crates/db/Cargo.toml COPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml COPY crates/executors/Cargo.toml crates/executors/Cargo.toml COPY crates/git/Cargo.toml crates/git/Cargo.toml COPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml COPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml COPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml COPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml COPY crates/relay-tunnel/Cargo.toml crates/relay-tunnel/Cargo.toml COPY crates/review/Cargo.toml crates/review/Cargo.toml COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml COPY crates/services/Cargo.toml crates/services/Cargo.toml COPY crates/tauri-app/Cargo.toml crates/tauri-app/Cargo.toml COPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml COPY crates/utils/Cargo.toml crates/utils/Cargo.toml COPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml COPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml COPY crates/api-types/ crates/api-types/ COPY crates/db/ crates/db/ COPY crates/deployment/ crates/deployment/ COPY crates/executors/ crates/executors/ COPY crates/git/ crates/git/ COPY crates/git-host/ crates/git-host/ COPY crates/local-deployment/ crates/local-deployment/ COPY crates/mcp/ crates/mcp/ COPY crates/relay-control/ crates/relay-control/ COPY crates/relay-tunnel/ crates/relay-tunnel/ COPY crates/review/ crates/review/ COPY crates/server/ crates/server/ COPY crates/server-info/ crates/server-info/ COPY crates/services/ crates/services/ COPY crates/trusted-key-auth/ crates/trusted-key-auth/ COPY crates/utils/ crates/utils/ COPY crates/workspace-manager/ crates/workspace-manager/ COPY crates/worktree-manager/ crates/worktree-manager/ COPY assets/ assets/ COPY --from=fe-builder /app/packages/local-web/dist packages/local-web/dist RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \ --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \ --mount=type=cache,id=workspace-target,target=/app/target \ cargo build --locked --release --bin server \ && cp /app/target/release/server /usr/local/bin/server FROM debian:bookworm-slim AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ git \ openssh-client \ tini \ wget \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --create-home --uid 10001 appuser WORKDIR /repos COPY --from=builder /usr/local/bin/server /usr/local/bin/server RUN mkdir -p /repos \ && chown -R appuser:appuser /repos USER appuser ENV HOST=0.0.0.0 ENV PORT=3000 EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["/bin/sh", "-c", "wget --spider -q http://127.0.0.1:${PORT:-3000}/health"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/server"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Vibe Kanban Logo

Get 10X more out of Claude Code, Gemini CLI, Codex, Amp and other coding agents...

npm Build status Ask DeepWiki

We're hiring!

![](packages/public/vibe-kanban-screenshot-overview.png) ## Overview In a world where software engineers spend most of their time planning and reviewing coding agents, the most impactful way to ship more is to get faster at planning and review. Vibe Kanban is built for this. Use kanban issues to plan work, either privately or with your team. When you're ready to begin, create workspaces where coding agents can execute. - **Plan with kanban issues** — create, prioritise, and assign issues on a kanban board - **Run coding agents in workspaces** — each workspace gives an agent a branch, a terminal, and a dev server - **Review diffs and leave inline comments** — send feedback directly to the agent without leaving the UI - **Preview your app** — built-in browser with devtools, inspect mode, and device emulation - **Switch between 10+ coding agents** — Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, and Qwen Code - **Create pull requests and merge** — open PRs with AI-generated descriptions, review on GitHub, and merge ![](packages/public/vibe-kanban-screenshot-workspace.png) One command. Describe the work, review the diff, ship it. ```bash npx vibe-kanban ``` ## Installation Make sure you have authenticated with your favourite coding agent. A full list of supported coding agents can be found in the [docs](https://vibekanban.com/docs/supported-coding-agents). Then in your terminal run: ```bash npx vibe-kanban ``` ## Documentation Head to the [website](https://vibekanban.com/docs) for the latest documentation and user guides. ## Self-Hosting Want to host your own Vibe Kanban Cloud instance? See our [self-hosting guide](https://vibekanban.com/docs/self-hosting/deploy-docker). ## Support We use [GitHub Discussions](https://github.com/BloopAI/vibe-kanban/discussions) for feature requests. Please open a discussion to create a feature request. For bugs please open an issue on this repo. ## Contributing We would prefer that ideas and changes are first raised with the core team via [GitHub Discussions](https://github.com/BloopAI/vibe-kanban/discussions) or [Discord](https://discord.gg/AC4nwVtJM3), where we can discuss implementation details and alignment with the existing roadmap. Please do not open PRs without first discussing your proposal with the team. ## Development ### Prerequisites - [Rust](https://rustup.rs/) (latest stable) - [Node.js](https://nodejs.org/) (>=20) - [pnpm](https://pnpm.io/) (>=8) Additional development tools: ```bash cargo install cargo-watch cargo install sqlx-cli ``` Install dependencies: ```bash pnpm i ``` ### Running the dev server ```bash pnpm run dev ``` This will start the backend and web app. A blank DB will be copied from the `dev_assets_seed` folder. ### Building the web app To build just the web app: ```bash cd packages/local-web pnpm run build ``` ### Build from source (macOS) 1. Run `./local-build.sh` 2. Test with `cd npx-cli && node bin/cli.js` ### Environment Variables The following environment variables can be configured at build time or runtime: | Variable | Type | Default | Description | |----------|------|---------|-------------| | `POSTHOG_API_KEY` | Build-time | Empty | PostHog analytics API key (disables analytics if empty) | | `POSTHOG_API_ENDPOINT` | Build-time | Empty | PostHog analytics endpoint (disables analytics if empty) | | `PORT` | Runtime | Auto-assign | **Production**: Server port. **Dev**: Frontend port (backend uses PORT+1) | | `BACKEND_PORT` | Runtime | `0` (auto-assign) | Backend server port (dev mode only, overrides PORT+1) | | `FRONTEND_PORT` | Runtime | `3000` | Frontend dev server port (dev mode only, overrides PORT) | | `HOST` | Runtime | `127.0.0.1` | Backend server host | | `MCP_HOST` | Runtime | Value of `HOST` | MCP server connection host (use `127.0.0.1` when `HOST=0.0.0.0` on Windows) | | `MCP_PORT` | Runtime | Value of `BACKEND_PORT` | MCP server connection port | | `DISABLE_WORKTREE_CLEANUP` | Runtime | Not set | Disable all git worktree cleanup including orphan and expired workspace cleanup (for debugging) | | `VK_ALLOWED_ORIGINS` | Runtime | Not set | Comma-separated list of origins that are allowed to make backend API requests (e.g., `https://my-vibekanban-frontend.com`) | | `VK_SHARED_API_BASE` | Runtime | Not set | Base URL for the remote/cloud API used by the local desktop app | | `VK_SHARED_RELAY_API_BASE` | Runtime | Not set | Base URL for the relay API used by tunnel-mode connections | | `VK_TUNNEL` | Runtime | Not set | Enable relay tunnel mode when set (requires relay API base URL) | **Build-time variables** must be set when running `pnpm run build`. **Runtime variables** are read when the application starts. #### Self-Hosting with a Reverse Proxy or Custom Domain When running Vibe Kanban behind a reverse proxy (e.g., nginx, Caddy, Traefik) or on a custom domain, you must set the `VK_ALLOWED_ORIGINS` environment variable. Without this, the browser's Origin header won't match the backend's expected host, and API requests will be rejected with a 403 Forbidden error. Set it to the full origin URL(s) where your frontend is accessible: ```bash # Single origin VK_ALLOWED_ORIGINS=https://vk.example.com # Multiple origins (comma-separated) VK_ALLOWED_ORIGINS=https://vk.example.com,https://vk-staging.example.com ``` ### Remote Deployment When running Vibe Kanban on a remote server (e.g., via systemctl, Docker, or cloud hosting), you can configure your editor to open projects via SSH: 1. **Access via tunnel**: Use Cloudflare Tunnel, ngrok, or similar to expose the web UI 2. **Configure remote SSH** in Settings → Editor Integration: - Set **Remote SSH Host** to your server hostname or IP - Set **Remote SSH User** to your SSH username (optional) 3. **Prerequisites**: - SSH access from your local machine to the remote server - SSH keys configured (passwordless authentication) - VSCode Remote-SSH extension When configured, the "Open in VSCode" buttons will generate URLs like `vscode://vscode-remote/ssh-remote+user@host/path` that open your local editor and connect to the remote server. See the [documentation](https://vibekanban.com/docs/settings/general) for detailed setup instructions. ================================================ FILE: assets/scripts/toast-notification.ps1 ================================================ param( [Parameter(Mandatory=$true)] [string]$Title, [Parameter(Mandatory=$true)] [string]$Message, [Parameter(Mandatory=$false)] [string]$AppName = "Vibe Kanban" ) [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null $Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) $RawXml = [xml] $Template.GetXml() ($RawXml.toast.visual.binding.text|where {$_.id -eq "1"}).AppendChild($RawXml.CreateTextNode($Title)) | Out-Null ($RawXml.toast.visual.binding.text|where {$_.id -eq "2"}).AppendChild($RawXml.CreateTextNode($Message)) | Out-Null $SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument $SerializedXml.LoadXml($RawXml.OuterXml) $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) $Toast.Tag = $AppName $Toast.Group = $AppName $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppName) $Notifier.Show($Toast) ================================================ FILE: crates/api-types/Cargo.toml ================================================ [package] name = "api-types" version = "0.1.33" edition = "2024" [dependencies] chrono = { version = "0.4", features = ["serde"] } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "uuid", "chrono", "derive"] } ts-rs = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } ================================================ FILE: crates/api-types/src/attachment.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; /// An attachment links a blob to an issue or comment. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Attachment { pub id: Uuid, pub blob_id: Uuid, pub issue_id: Option, pub comment_id: Option, pub created_at: DateTime, pub expires_at: Option>, } /// An attachment with its associated blob data (for API responses). #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AttachmentWithBlob { pub id: Uuid, pub blob_id: Uuid, pub issue_id: Option, pub comment_id: Option, pub created_at: DateTime, pub expires_at: Option>, // Blob fields pub blob_path: String, pub thumbnail_blob_path: Option, pub original_name: String, pub mime_type: Option, pub size_bytes: i64, pub hash: String, pub width: Option, pub height: Option, } /// An attachment with blob data and a presigned file URL. #[derive(Debug, Serialize, Deserialize)] pub struct AttachmentWithUrl { #[serde(flatten)] pub attachment: AttachmentWithBlob, pub file_url: Option, } /// Response from listing attachments. #[derive(Debug, Serialize, Deserialize)] pub struct ListAttachmentsResponse { pub attachments: Vec, } /// Response containing a presigned URL for an attachment file or thumbnail. #[derive(Debug, Serialize, Deserialize, TS)] pub struct AttachmentUrlResponse { pub url: String, } ================================================ FILE: crates/api-types/src/auth.rs ================================================ use chrono::{DateTime, Duration, Utc}; use serde::Serialize; use uuid::Uuid; #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct AuthSession { pub id: Uuid, pub user_id: Uuid, pub created_at: DateTime, pub last_used_at: Option>, pub revoked_at: Option>, pub refresh_token_id: Option, pub refresh_token_issued_at: Option>, } impl AuthSession { pub fn last_activity_at(&self) -> DateTime { self.last_used_at.unwrap_or(self.created_at) } pub fn inactivity_duration(&self, now: DateTime) -> Duration { now.signed_duration_since(self.last_activity_at()) } } ================================================ FILE: crates/api-types/src/blob.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Blob { pub id: Uuid, pub project_id: Uuid, pub blob_path: String, pub thumbnail_blob_path: Option, pub original_name: String, pub mime_type: Option, pub size_bytes: i64, pub hash: String, pub width: Option, pub height: Option, pub created_at: DateTime, pub updated_at: DateTime, } ================================================ FILE: crates/api-types/src/issue.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[sqlx(type_name = "issue_priority", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum IssuePriority { Urgent, High, Medium, Low, } #[derive(Debug, Clone, Serialize, Deserialize, TS, sqlx::FromRow)] pub struct Issue { pub id: Uuid, pub project_id: Uuid, pub issue_number: i32, pub simple_id: String, pub status_id: Uuid, pub title: String, pub description: Option, pub priority: Option, pub start_date: Option>, pub target_date: Option>, pub completed_at: Option>, pub sort_order: f64, pub parent_issue_id: Option, pub parent_issue_sort_order: Option, pub extension_metadata: Value, pub creator_user_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] pub enum IssueSortField { SortOrder, Priority, CreatedAt, UpdatedAt, Title, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] pub enum SortDirection { Asc, Desc, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateIssueRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub project_id: Uuid, pub status_id: Uuid, pub title: String, pub description: Option, pub priority: Option, pub start_date: Option>, pub target_date: Option>, pub completed_at: Option>, pub sort_order: f64, pub parent_issue_id: Option, pub parent_issue_sort_order: Option, pub extension_metadata: Value, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UpdateIssueRequest { #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub status_id: Option, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub title: Option, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub description: Option>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub priority: Option>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub start_date: Option>>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub target_date: Option>>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub completed_at: Option>>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub sort_order: Option, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub parent_issue_id: Option>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub parent_issue_sort_order: Option>, #[serde( default, deserialize_with = "some_if_present", skip_serializing_if = "Option::is_none" )] pub extension_metadata: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListIssuesQuery { pub project_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct SearchIssuesRequest { pub project_id: Uuid, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub status_id: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub status_ids: Option>, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub parent_issue_id: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub search: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub simple_id: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub assignee_user_id: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub tag_id: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub tag_ids: Option>, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub sort_field: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub sort_direction: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, #[ts(optional)] #[serde(skip_serializing_if = "Option::is_none")] pub offset: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListIssuesResponse { pub issues: Vec, pub total_count: usize, pub limit: usize, pub offset: usize, } ================================================ FILE: crates/api-types/src/issue_assignee.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueAssignee { pub id: Uuid, pub issue_id: Uuid, pub user_id: Uuid, pub assigned_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateIssueAssigneeRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub issue_id: Uuid, pub user_id: Uuid, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueAssigneeRequest { #[serde(default, deserialize_with = "some_if_present")] pub user_id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueAssigneesQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListIssueAssigneesResponse { pub issue_assignees: Vec, } ================================================ FILE: crates/api-types/src/issue_comment.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueComment { pub id: Uuid, pub issue_id: Uuid, pub author_id: Option, pub parent_id: Option, pub message: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateIssueCommentRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub issue_id: Uuid, pub message: String, pub parent_id: Option, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueCommentRequest { #[serde(default, deserialize_with = "some_if_present")] pub message: Option, #[serde(default, deserialize_with = "some_if_present")] pub parent_id: Option>, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueCommentsQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, TS)] pub struct ListIssueCommentsResponse { pub issue_comments: Vec, } ================================================ FILE: crates/api-types/src/issue_comment_reaction.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueCommentReaction { pub id: Uuid, pub comment_id: Uuid, pub user_id: Uuid, pub emoji: String, pub created_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateIssueCommentReactionRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub comment_id: Uuid, pub emoji: String, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueCommentReactionRequest { #[serde(default, deserialize_with = "some_if_present")] pub emoji: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueCommentReactionsQuery { pub comment_id: Uuid, } #[derive(Debug, Clone, Serialize, TS)] pub struct ListIssueCommentReactionsResponse { pub issue_comment_reactions: Vec, } ================================================ FILE: crates/api-types/src/issue_follower.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueFollower { pub id: Uuid, pub issue_id: Uuid, pub user_id: Uuid, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateIssueFollowerRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub issue_id: Uuid, pub user_id: Uuid, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueFollowerRequest { #[serde(default, deserialize_with = "some_if_present")] pub user_id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueFollowersQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, TS)] pub struct ListIssueFollowersResponse { pub issue_followers: Vec, } ================================================ FILE: crates/api-types/src/issue_relationship.rs ================================================ use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS, JsonSchema)] #[sqlx(type_name = "issue_relationship_type", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum IssueRelationshipType { Blocking, Related, HasDuplicate, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueRelationship { pub id: Uuid, pub issue_id: Uuid, pub related_issue_id: Uuid, pub relationship_type: IssueRelationshipType, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateIssueRelationshipRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub issue_id: Uuid, pub related_issue_id: Uuid, pub relationship_type: IssueRelationshipType, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueRelationshipRequest { #[serde(default, deserialize_with = "some_if_present")] pub related_issue_id: Option, #[serde(default, deserialize_with = "some_if_present")] pub relationship_type: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueRelationshipsQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListIssueRelationshipsResponse { pub issue_relationships: Vec, } ================================================ FILE: crates/api-types/src/issue_tag.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct IssueTag { pub id: Uuid, pub issue_id: Uuid, pub tag_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateIssueTagRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub issue_id: Uuid, pub tag_id: Uuid, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateIssueTagRequest { #[serde(default, deserialize_with = "some_if_present")] pub tag_id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListIssueTagsQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListIssueTagsResponse { pub issue_tags: Vec, } ================================================ FILE: crates/api-types/src/lib.rs ================================================ //! API types shared between local and remote backends. //! //! This crate contains: //! - Row types (e.g., `Issue`, `Project`) - the API representation of database entities //! - Request types (e.g., `CreateIssueRequest`, `UpdateIssueRequest`) - API input types //! - Shared enums (e.g., `IssuePriority`, `PullRequestStatus`) use serde::{Deserialize, Deserializer}; pub mod attachment; pub mod auth; pub mod blob; pub mod issue; pub mod issue_assignee; pub mod issue_comment; pub mod issue_comment_reaction; pub mod issue_follower; pub mod issue_relationship; pub mod issue_tag; pub mod migration; pub mod notification; pub mod oauth; pub mod organization_member; pub mod organizations; pub mod project; pub mod project_status; pub mod pull_request; pub mod pull_requests_local; pub mod relay; pub mod response; pub mod tag; pub mod user; pub mod workspace; pub mod workspaces; pub use attachment::*; pub use auth::*; pub use blob::*; pub use issue::*; pub use issue_assignee::*; pub use issue_comment::*; pub use issue_comment_reaction::*; pub use issue_follower::*; pub use issue_relationship::*; pub use issue_tag::*; pub use migration::*; pub use notification::*; pub use oauth::*; pub use organization_member::*; pub use organizations::*; pub use project::*; pub use project_status::*; pub use pull_request::*; pub use pull_requests_local::*; pub use relay::*; pub use response::*; pub use tag::*; pub use user::*; pub use workspace::*; pub use workspaces::*; pub fn some_if_present<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, T: Deserialize<'de>, { T::deserialize(deserializer).map(Some) } ================================================ FILE: crates/api-types/src/migration.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigrateProjectRequest { pub organization_id: Uuid, pub name: String, pub color: String, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigrateIssueRequest { pub project_id: Uuid, pub status_name: String, pub title: String, pub description: Option, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigratePullRequestRequest { pub url: String, pub number: i32, pub status: String, pub merged_at: Option>, pub merge_commit_sha: Option, pub target_branch_name: String, pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigrateWorkspaceRequest { pub project_id: Uuid, pub issue_id: Option, pub local_workspace_id: Uuid, pub archived: bool, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BulkMigrateRequest { pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BulkMigrateResponse { pub ids: Vec, } ================================================ FILE: crates/api-types/src/notification.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; use crate::{IssuePriority, some_if_present}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[serde(rename_all = "snake_case")] #[sqlx(type_name = "notification_type", rename_all = "snake_case")] pub enum NotificationType { IssueCommentAdded, IssueStatusChanged, IssueAssigneeChanged, IssuePriorityChanged, IssueUnassigned, IssueCommentReaction, IssueDeleted, IssueTitleChanged, IssueDescriptionChanged, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] pub enum NotificationGroupKind { Single, IssueChanges, StatusChanges, Comments, Reactions, IssueDeleted, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Notification { pub id: Uuid, pub organization_id: Uuid, pub user_id: Uuid, pub notification_type: NotificationType, pub payload: NotificationPayload, pub issue_id: Option, pub comment_id: Option, pub seen: bool, pub dismissed_at: Option>, pub created_at: DateTime, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] pub struct NotificationPayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub deeplink_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub issue_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub issue_simple_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub issue_title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub actor_user_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub comment_preview: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub old_status_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_status_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub old_status_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_status_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub old_priority: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_priority: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub assignee_user_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub emoji: Option, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateNotificationRequest { #[serde(default, deserialize_with = "some_if_present")] pub seen: Option, } ================================================ FILE: crates/api-types/src/oauth.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct HandoffInitRequest { pub provider: String, pub return_to: String, pub app_challenge: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct HandoffInitResponse { pub handoff_id: Uuid, pub authorize_url: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct HandoffRedeemRequest { pub handoff_id: Uuid, pub app_code: String, pub app_verifier: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct HandoffRedeemResponse { pub access_token: String, pub refresh_token: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct TokenRefreshRequest { pub refresh_token: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct TokenRefreshResponse { pub access_token: String, pub refresh_token: String, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct ProviderProfile { pub provider: String, pub username: Option, pub display_name: Option, pub email: Option, pub avatar_url: Option, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct ProfileResponse { pub user_id: Uuid, pub username: Option, pub email: String, pub providers: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] #[serde(tag = "status", rename_all = "lowercase")] pub enum LoginStatus { LoggedOut, LoggedIn { profile: ProfileResponse }, } #[derive(Debug, Serialize, Deserialize, Clone, TS)] pub struct StatusResponse { pub logged_in: bool, #[serde(skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub degraded: Option, } ================================================ FILE: crates/api-types/src/organization_member.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[sqlx(type_name = "member_role", rename_all = "lowercase")] #[ts(use_ts_enum)] #[ts(rename_all = "SCREAMING_SNAKE_CASE")] pub enum MemberRole { Admin, Member, } /// Organization member as stored in the database / streamed via Electric. /// This is the full row type with organization_id for shapes. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OrganizationMember { pub organization_id: Uuid, pub user_id: Uuid, pub role: MemberRole, pub joined_at: DateTime, pub last_seen_at: Option>, } ================================================ FILE: crates/api-types/src/organizations.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; use crate::MemberRole; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[sqlx(type_name = "invitation_status", rename_all = "lowercase")] #[ts(use_ts_enum)] #[ts(rename_all = "SCREAMING_SNAKE_CASE")] pub enum InvitationStatus { Pending, Accepted, Declined, Expired, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct Organization { pub id: Uuid, pub name: String, pub slug: String, pub is_personal: bool, pub issue_prefix: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct OrganizationWithRole { pub id: Uuid, pub name: String, pub slug: String, pub is_personal: bool, pub issue_prefix: String, pub created_at: DateTime, pub updated_at: DateTime, pub user_role: MemberRole, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListOrganizationsResponse { pub organizations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct GetOrganizationResponse { pub organization: Organization, pub user_role: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateOrganizationRequest { pub name: String, pub slug: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateOrganizationResponse { pub organization: OrganizationWithRole, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UpdateOrganizationRequest { pub name: String, } // Invitation types #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Invitation { pub id: Uuid, pub organization_id: Uuid, pub invited_by_user_id: Option, pub email: String, pub role: MemberRole, pub status: InvitationStatus, pub token: String, pub created_at: DateTime, pub expires_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateInvitationRequest { pub email: String, pub role: MemberRole, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateInvitationResponse { pub invitation: Invitation, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListInvitationsResponse { pub invitations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct GetInvitationResponse { pub id: Uuid, pub organization_slug: String, pub role: MemberRole, pub expires_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AcceptInvitationResponse { pub organization_id: String, pub organization_slug: String, pub role: MemberRole, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct RevokeInvitationRequest { pub invitation_id: Uuid, } // Member types /// Organization member info for API responses (without organization_id). /// See also `OrganizationMember` in organization_member.rs for the full DB row type. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OrganizationMemberInfo { pub user_id: Uuid, pub role: MemberRole, pub joined_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OrganizationMemberWithProfile { pub user_id: Uuid, pub role: MemberRole, pub joined_at: DateTime, pub first_name: Option, pub last_name: Option, pub username: Option, pub email: Option, pub avatar_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListMembersResponse { pub members: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UpdateMemberRoleRequest { pub role: MemberRole, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UpdateMemberRoleResponse { pub user_id: Uuid, pub role: MemberRole, } ================================================ FILE: crates/api-types/src/project.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Project { pub id: Uuid, pub organization_id: Uuid, pub name: String, pub color: String, pub sort_order: i32, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateProjectRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub organization_id: Uuid, pub name: String, pub color: String, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateProjectRequest { #[serde(default, deserialize_with = "some_if_present")] pub name: Option, #[serde(default, deserialize_with = "some_if_present")] pub color: Option, #[serde(default, deserialize_with = "some_if_present")] pub sort_order: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListProjectsQuery { pub organization_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListProjectsResponse { pub projects: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct BulkUpdateProjectItem { pub id: Uuid, #[serde(flatten)] pub changes: UpdateProjectRequest, } #[derive(Debug, Clone, Deserialize)] pub struct BulkUpdateProjectsRequest { pub updates: Vec, } #[derive(Debug, Clone, Serialize)] pub struct BulkUpdateProjectsResponse { pub data: Vec, pub txid: i64, } ================================================ FILE: crates/api-types/src/project_status.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ProjectStatus { pub id: Uuid, pub project_id: Uuid, pub name: String, pub color: String, pub sort_order: i32, pub hidden: bool, pub created_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateProjectStatusRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub project_id: Uuid, pub name: String, pub color: String, pub sort_order: i32, pub hidden: bool, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateProjectStatusRequest { #[serde(default, deserialize_with = "some_if_present")] pub name: Option, #[serde(default, deserialize_with = "some_if_present")] pub color: Option, #[serde(default, deserialize_with = "some_if_present")] pub sort_order: Option, #[serde(default, deserialize_with = "some_if_present")] pub hidden: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListProjectStatusesQuery { pub project_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListProjectStatusesResponse { pub project_statuses: Vec, } ================================================ FILE: crates/api-types/src/pull_request.rs ================================================ use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sqlx::Type; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS, JsonSchema)] #[sqlx(type_name = "pull_request_status", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum PullRequestStatus { Open, Merged, Closed, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PullRequest { pub id: Uuid, pub url: String, pub number: i32, pub status: PullRequestStatus, pub merged_at: Option>, pub merge_commit_sha: Option, pub target_branch_name: String, pub issue_id: Uuid, pub workspace_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Deserialize)] pub struct ListPullRequestsQuery { pub issue_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListPullRequestsResponse { pub pull_requests: Vec, } ================================================ FILE: crates/api-types/src/pull_requests_local.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::PullRequestStatus; #[derive(Debug, Deserialize, Serialize)] pub struct UpsertPullRequestRequest { pub url: String, pub number: i32, pub status: PullRequestStatus, #[serde(skip_serializing_if = "Option::is_none")] pub merged_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub merge_commit_sha: Option, pub target_branch_name: String, pub local_workspace_id: Uuid, } ================================================ FILE: crates/api-types/src/relay.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct RelayHost { pub id: Uuid, pub owner_user_id: Uuid, pub name: String, pub status: String, pub last_seen_at: Option>, pub agent_version: Option, pub created_at: DateTime, pub updated_at: DateTime, pub access_role: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListRelayHostsResponse { pub hosts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct RelaySessionAuthCodeResponse { pub session_id: Uuid, pub code: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct RelaySession { pub id: Uuid, pub host_id: Uuid, pub request_user_id: Uuid, pub state: String, pub created_at: DateTime, pub expires_at: DateTime, pub claimed_at: Option>, pub ended_at: Option>, } ================================================ FILE: crates/api-types/src/response.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// Response wrapper for mutation endpoints (create/update). /// Includes the Postgres transaction ID for Electric sync. #[derive(Debug, Serialize, Deserialize)] pub struct MutationResponse { pub data: T, pub txid: i64, } /// Response wrapper for delete endpoints. #[derive(Debug, Serialize, Deserialize, TS)] pub struct DeleteResponse { pub txid: i64, } ================================================ FILE: crates/api-types/src/tag.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::some_if_present; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Tag { pub id: Uuid, pub project_id: Uuid, pub name: String, pub color: String, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateTagRequest { /// Optional client-generated ID. If not provided, server generates one. /// Using client-generated IDs enables stable optimistic updates. #[ts(optional)] pub id: Option, pub project_id: Uuid, pub name: String, pub color: String, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateTagRequest { #[serde(default, deserialize_with = "some_if_present")] pub name: Option, #[serde(default, deserialize_with = "some_if_present")] pub color: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ListTagsQuery { pub project_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ListTagsResponse { pub tags: Vec, } ================================================ FILE: crates/api-types/src/user.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct User { pub id: Uuid, pub email: String, pub first_name: Option, pub last_name: Option, pub username: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UserData { pub user_id: Uuid, pub first_name: Option, pub last_name: Option, pub username: Option, } ================================================ FILE: crates/api-types/src/workspace.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; /// Workspace metadata pushed from local clients #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] pub struct Workspace { pub id: Uuid, pub project_id: Uuid, pub owner_user_id: Uuid, pub issue_id: Option, pub local_workspace_id: Option, pub name: Option, pub archived: bool, pub files_changed: Option, pub lines_added: Option, pub lines_removed: Option, pub created_at: DateTime, pub updated_at: DateTime, } ================================================ FILE: crates/api-types/src/workspaces.rs ================================================ use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Deserialize, Serialize)] pub struct DeleteWorkspaceRequest { pub local_workspace_id: Uuid, } #[derive(Debug, Serialize)] pub struct CreateWorkspaceRequest { pub project_id: Uuid, pub local_workspace_id: Uuid, pub issue_id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub archived: Option, #[serde(skip_serializing_if = "Option::is_none")] pub files_changed: Option, #[serde(skip_serializing_if = "Option::is_none")] pub lines_added: Option, #[serde(skip_serializing_if = "Option::is_none")] pub lines_removed: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct UpdateWorkspaceRequest { pub local_workspace_id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub archived: Option, #[serde(skip_serializing_if = "Option::is_none")] pub files_changed: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub lines_added: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub lines_removed: Option>, } ================================================ FILE: crates/db/.sqlx/query-039c2290b6cf7cdc905c8ddc44293f067fe7e8f246da737e4baad3f494ac8b8f.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO execution_processes (\n id, session_id, run_reason, executor_action,\n status, exit_code, started_at, completed_at, created_at, updated_at\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { "Right": 10 }, "nullable": [] }, "hash": "039c2290b6cf7cdc905c8ddc44293f067fe7e8f246da737e4baad3f494ac8b8f" } ================================================ FILE: crates/db/.sqlx/query-04c207be2c3c2c07ff42c695542504c358d67c1f40ca2b1e75a396a90c173a53.json ================================================ { "db_name": "SQLite", "query": "SELECT eprs.after_head_commit\n FROM execution_process_repo_states eprs\n JOIN execution_processes ep ON ep.id = eprs.execution_process_id\n WHERE ep.session_id = $1\n AND eprs.repo_id = $2\n AND ep.created_at < (SELECT created_at FROM execution_processes WHERE id = $3)\n ORDER BY ep.created_at DESC\n LIMIT 1", "describe": { "columns": [ { "name": "after_head_commit", "ordinal": 0, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ true ] }, "hash": "04c207be2c3c2c07ff42c695542504c358d67c1f40ca2b1e75a396a90c173a53" } ================================================ FILE: crates/db/.sqlx/query-04e5a05c7cad438d39c4c8590410889ab1eefa7376d474a10c119d3f4d9143c7.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n execution_process_id as \"execution_process_id!: Uuid\",\n agent_session_id,\n agent_message_id,\n prompt,\n summary,\n seen as \"seen!: bool\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM coding_agent_turns\n WHERE agent_session_id = ?\n ORDER BY updated_at DESC\n LIMIT 1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "execution_process_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "agent_session_id", "ordinal": 2, "type_info": "Text" }, { "name": "agent_message_id", "ordinal": 3, "type_info": "Text" }, { "name": "prompt", "ordinal": 4, "type_info": "Text" }, { "name": "summary", "ordinal": 5, "type_info": "Text" }, { "name": "seen!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, true, true, true, true, false, false, false ] }, "hash": "04e5a05c7cad438d39c4c8590410889ab1eefa7376d474a10c119d3f4d9143c7" } ================================================ FILE: crates/db/.sqlx/query-04f17449e3e12785affab91e4eab308103491e34c022199b7b060e04fa8aed0f.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n file_path as \"file_path!\",\n original_name as \"original_name!\",\n mime_type,\n size_bytes as \"size_bytes!\",\n hash as \"hash!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM attachments\n WHERE hash = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "04f17449e3e12785affab91e4eab308103491e34c022199b7b060e04fa8aed0f" } ================================================ FILE: crates/db/.sqlx/query-0a805c219c9028b2677bd94ccabd47916e60d26c1cede27e467f0ae91f6639ab.json ================================================ { "db_name": "SQLite", "query": "UPDATE migration_state\n SET status = 'migrated',\n remote_id = $3,\n error_message = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE entity_type = $1 AND local_id = $2", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "0a805c219c9028b2677bd94ccabd47916e60d26c1cede27e467f0ae91f6639ab" } ================================================ FILE: crates/db/.sqlx/query-0ab07fb562e61148f3f07f33f766ea12c73d467df4522240008370f681c8409a.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspace_repos\n SET target_branch = $1, updated_at = datetime('now')\n WHERE target_branch = $2\n AND workspace_id IN (\n SELECT w.id FROM workspaces w\n JOIN tasks t ON w.task_id = t.id\n WHERE t.parent_workspace_id = $3\n )", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "0ab07fb562e61148f3f07f33f766ea12c73d467df4522240008370f681c8409a" } ================================================ FILE: crates/db/.sqlx/query-0ac0d0f3826330836e3fd1bf57c42777eb489ac41a650f9361e6b563fc69bf35.json ================================================ { "db_name": "SQLite", "query": "UPDATE migration_state\n SET status = 'pending',\n error_message = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE status = 'failed'", "describe": { "columns": [], "parameters": { "Right": 0 }, "nullable": [] }, "hash": "0ac0d0f3826330836e3fd1bf57c42777eb489ac41a650f9361e6b563fc69bf35" } ================================================ FILE: crates/db/.sqlx/query-0c7b20643f119afd3e233105b0fa2920e8e940bdad86cdc95d01e485a20d6ed4.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n WHERE s.workspace_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\n ORDER BY ep.created_at DESC LIMIT 1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "0c7b20643f119afd3e233105b0fa2920e8e940bdad86cdc95d01e485a20d6ed4" } ================================================ FILE: crates/db/.sqlx/query-0f90844fc62261ed140e02515ae464b940743113814507313c9fdc176000d1bf.json ================================================ { "db_name": "SQLite", "query": "UPDATE migration_state\n SET status = 'skipped',\n error_message = $3,\n updated_at = datetime('now', 'subsec')\n WHERE entity_type = $1 AND local_id = $2", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "0f90844fc62261ed140e02515ae464b940743113814507313c9fdc176000d1bf" } ================================================ FILE: crates/db/.sqlx/query-1085d1f8107c7e16fc2058ef610918760d8d420f0fca97adecd76d698f6f3a51.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n target_branch_name as \"target_branch_name!: String\",\n created_at as \"created_at!: DateTime\"\n FROM merges\n WHERE workspace_id = $1 AND repo_id = $2\n ORDER BY created_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 10, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "1085d1f8107c7e16fc2058ef610918760d8d420f0fca97adecd76d698f6f3a51" } ================================================ FILE: crates/db/.sqlx/query-11793c98a4bee67fce9972ed6b10a18226e0455a0e8d113d04c4d5148b72aec7.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tags\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "tag_name", "ordinal": 1, "type_info": "Text" }, { "name": "content!", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, false ] }, "hash": "11793c98a4bee67fce9972ed6b10a18226e0455a0e8d113d04c4d5148b72aec7" } ================================================ FILE: crates/db/.sqlx/query-12a5c0a8b95d8cb87f1c869ff35692a2cee52bc418b06d00a31a4c139e12d18a.json ================================================ { "db_name": "SQLite", "query": "\n SELECT \n ep.id as \"execution_id!: Uuid\", \n ep.session_id as \"session_id!: Uuid\"\n FROM execution_processes ep\n WHERE EXISTS (\n SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id\n )\n ", "describe": { "columns": [ { "name": "execution_id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" } ], "parameters": { "Right": 0 }, "nullable": [ true, false ] }, "hash": "12a5c0a8b95d8cb87f1c869ff35692a2cee52bc418b06d00a31a4c139e12d18a" } ================================================ FILE: crates/db/.sqlx/query-13826fc6fdd367255cb921640e5972f30905ac7a81ad477cf8bbcfc24f06f39b.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n WHERE s.workspace_id = ?\n AND ep.status = 'running'\n AND ep.run_reason = 'devserver'\n ORDER BY ep.created_at DESC\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "13826fc6fdd367255cb921640e5972f30905ac7a81ad477cf8bbcfc24f06f39b" } ================================================ FILE: crates/db/.sqlx/query-167c1d13ee37ebe62cd2316feaf6b5354eb26d0a8fc16efb22827a5cde59a60e.json ================================================ { "db_name": "SQLite", "query": "SELECT COUNT(1) as \"count!: i64\"\n FROM merges\n WHERE workspace_id = $1\n AND merge_type = 'pr'\n AND pr_status = 'open'", "describe": { "columns": [ { "name": "count!: i64", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "167c1d13ee37ebe62cd2316feaf6b5354eb26d0a8fc16efb22827a5cde59a60e" } ================================================ FILE: crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json ================================================ { "db_name": "SQLite", "query": "SELECT COUNT(*) as \"count!: i64\"\n FROM execution_processes ep\n WHERE ep.session_id = $1\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'", "describe": { "columns": [ { "name": "count!: i64", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da" } ================================================ FILE: crates/db/.sqlx/query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM workspaces WHERE id = $1", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74" } ================================================ FILE: crates/db/.sqlx/query-218f1d14c72148ea88d75e816e1ba111c8f4678a7e428b15462e6dfc74c25b03.json ================================================ { "db_name": "SQLite", "query": "UPDATE tags\n SET tag_name = $2, content = $3, updated_at = datetime('now', 'subsec')\n WHERE id = $1\n RETURNING id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "tag_name", "ordinal": 1, "type_info": "Text" }, { "name": "content!", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ true, false, false, false, false ] }, "hash": "218f1d14c72148ea88d75e816e1ba111c8f4678a7e428b15462e6dfc74c25b03" } ================================================ FILE: crates/db/.sqlx/query-2547a5d06fd3b17360bff34a04b7d3d929c13ef0d86395a9201834d8fc955295.json ================================================ { "db_name": "SQLite", "query": "SELECT\n cat.agent_session_id as \"session_id!\",\n cat.agent_message_id as \"message_id\"\n FROM execution_processes ep\n JOIN coding_agent_turns cat ON ep.id = cat.execution_process_id\n WHERE ep.session_id = $1\n AND ep.run_reason = 'codingagent'\n AND ep.dropped = FALSE\n AND cat.agent_session_id IS NOT NULL\n ORDER BY ep.created_at DESC\n LIMIT 1", "describe": { "columns": [ { "name": "session_id!", "ordinal": 0, "type_info": "Text" }, { "name": "message_id", "ordinal": 1, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, true ] }, "hash": "2547a5d06fd3b17360bff34a04b7d3d929c13ef0d86395a9201834d8fc955295" } ================================================ FILE: crates/db/.sqlx/query-256f9e937384933464e6d4d00ee977bbb2915ef80930c8b5c0b0525367a5264d.json ================================================ { "db_name": "SQLite", "query": "UPDATE repos SET name = $1, display_name = $2, updated_at = datetime('now', 'subsec') WHERE id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "256f9e937384933464e6d4d00ee977bbb2915ef80930c8b5c0b0525367a5264d" } ================================================ FILE: crates/db/.sqlx/query-29cc3aa8d0ad5deda94494402500a4125e29381d63f18ef083cc4da95e2c5db5.json ================================================ { "db_name": "SQLite", "query": "SELECT w.id as \"workspace_id!: Uuid\"\n FROM workspaces w\n WHERE w.container_ref = ?", "describe": { "columns": [ { "name": "workspace_id!: Uuid", "ordinal": 0, "type_info": "Blob" } ], "parameters": { "Right": 1 }, "nullable": [ true ] }, "hash": "29cc3aa8d0ad5deda94494402500a4125e29381d63f18ef083cc4da95e2c5db5" } ================================================ FILE: crates/db/.sqlx/query-2a57b702e52b3cc9bdbc361267985958b11d4493b01a9ab8daedf5d951422897.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET updated_at = datetime('now', 'subsec') WHERE id = ?", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "2a57b702e52b3cc9bdbc361267985958b11d4493b01a9ab8daedf5d951422897" } ================================================ FILE: crates/db/.sqlx/query-2b253f92ac5daa4864e7335fde1b82625f504fd73d19b21992497219a9c3170a.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO repos (id, path, name, display_name)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT(path) DO UPDATE SET updated_at = updated_at\n RETURNING id as \"id!: Uuid\",\n path,\n name,\n display_name,\n setup_script,\n cleanup_script,\n archive_script,\n copy_files,\n parallel_setup_script as \"parallel_setup_script!: bool\",\n dev_server_script,\n default_target_branch,\n default_working_dir,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 4 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "2b253f92ac5daa4864e7335fde1b82625f504fd73d19b21992497219a9c3170a" } ================================================ FILE: crates/db/.sqlx/query-2c0172d5b2c5bff0914727a57983d5c336f5b2dfa73ca6c2efa4ea23bb526e05.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n name,\n default_agent_working_dir,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n ORDER BY created_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "name", "ordinal": 1, "type_info": "Text" }, { "name": "default_agent_working_dir", "ordinal": 2, "type_info": "Text" }, { "name": "remote_project_id: Uuid", "ordinal": 3, "type_info": "Blob" }, { "name": "created_at!: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 5, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, true, true, false, false ] }, "hash": "2c0172d5b2c5bff0914727a57983d5c336f5b2dfa73ca6c2efa4ea23bb526e05" } ================================================ FILE: crates/db/.sqlx/query-2c71bf4dd5683e0dedf2341e52880ff2c0765659d3cf53d62faa54adc91071dd.json ================================================ { "db_name": "SQLite", "query": "SELECT w.name AS \"name: String\"\n FROM workspaces w\n JOIN workspace_repos wr ON wr.workspace_id = w.id\n WHERE wr.repo_id = $1\n AND w.archived = FALSE", "describe": { "columns": [ { "name": "name: String", "ordinal": 0, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true ] }, "hash": "2c71bf4dd5683e0dedf2341e52880ff2c0765659d3cf53d62faa54adc91071dd" } ================================================ FILE: crates/db/.sqlx/query-2cb5a269045f23da9f4ee0ee679ccb7fffc39d4b37b1b58357b11a7abfdba125.json ================================================ { "db_name": "SQLite", "query": "UPDATE sessions SET executor = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "2cb5a269045f23da9f4ee0ee679ccb7fffc39d4b37b1b58357b11a7abfdba125" } ================================================ FILE: crates/db/.sqlx/query-31f4e685fc0b1103ff662b3866b3bb422cc7fc8e0661ebfed30ffd16ea7ed8c0.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n target_branch,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM workspace_repos\n WHERE workspace_id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "target_branch", "ordinal": 3, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 5, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, false, false ] }, "hash": "31f4e685fc0b1103ff662b3866b3bb422cc7fc8e0661ebfed30ffd16ea7ed8c0" } ================================================ FILE: crates/db/.sqlx/query-3266b6a544952177f84e2e7c31be9dba212c92d91b997de7f6aa811e08ed6c72.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep\n WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE\n ORDER BY ep.created_at DESC LIMIT 1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "3266b6a544952177f84e2e7c31be9dba212c92d91b997de7f6aa811e08ed6c72" } ================================================ FILE: crates/db/.sqlx/query-33f23656ba343bd75a88b0fadf2a4ba01eda330f9b549e625e27701e3b0b5a31.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM migration_state\n WHERE entity_type = $1 AND status = 'pending'\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "33f23656ba343bd75a88b0fadf2a4ba01eda330f9b549e625e27701e3b0b5a31" } ================================================ FILE: crates/db/.sqlx/query-3634e2bab8fef106721bb64a791edd81d3d49eb34fbabd34e4feadfb5f229a6e.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n container_ref as \"container_ref!\"\n FROM workspaces\n WHERE container_ref IS NOT NULL", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "container_ref!", "ordinal": 1, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, true ] }, "hash": "3634e2bab8fef106721bb64a791edd81d3d49eb34fbabd34e4feadfb5f229a6e" } ================================================ FILE: crates/db/.sqlx/query-3a9148e9e914d644d4d82a1c2dc8bd0e093d4f4c638afa7fd8f5211892fb6d84.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM repos WHERE id = $1", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "3a9148e9e914d644d4d82a1c2dc8bd0e093d4f4c638afa7fd8f5211892fb6d84" } ================================================ FILE: crates/db/.sqlx/query-3ace1ee8dba0669400d69891912b86823e41ca643092d990b12c1a6160112427.json ================================================ { "db_name": "SQLite", "query": "SELECT\n m.id as \"id!: Uuid\",\n m.workspace_id as \"workspace_id!: Uuid\",\n m.repo_id as \"repo_id!: Uuid\",\n m.merge_type as \"merge_type!: MergeType\",\n m.merge_commit,\n m.pr_number,\n m.pr_url,\n m.pr_status as \"pr_status?: MergeStatus\",\n m.pr_merged_at as \"pr_merged_at?: DateTime\",\n m.pr_merge_commit_sha,\n m.target_branch_name as \"target_branch_name!: String\",\n m.created_at as \"created_at!: DateTime\"\n FROM merges m\n INNER JOIN (\n SELECT workspace_id, MAX(created_at) as max_created_at\n FROM merges\n WHERE merge_type = 'pr'\n GROUP BY workspace_id\n ) latest ON m.workspace_id = latest.workspace_id\n AND m.created_at = latest.max_created_at\n INNER JOIN workspaces w ON m.workspace_id = w.id\n WHERE m.merge_type = 'pr' AND w.archived = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 10, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "3ace1ee8dba0669400d69891912b86823e41ca643092d990b12c1a6160112427" } ================================================ FILE: crates/db/.sqlx/query-3d34580933bc02a168f4a7c483460a6ad13ef72b508532f5a6cd5e53aff04a69.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n workspace_id as \"workspace_id!: Uuid\",\n execution_process_id as \"execution_process_id!: Uuid\",\n session_id as \"session_id!: Uuid\",\n status as \"status!: ExecutionProcessStatus\",\n completed_at as \"completed_at?: DateTime\"\n FROM (\n SELECT\n s.workspace_id,\n ep.id as execution_process_id,\n ep.session_id,\n ep.status,\n ep.completed_at,\n ROW_NUMBER() OVER (\n PARTITION BY s.workspace_id\n ORDER BY ep.created_at DESC\n ) as rn\n FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n JOIN workspaces w ON s.workspace_id = w.id\n WHERE w.archived = $1\n AND ep.run_reason IN ('codingagent', 'setupscript', 'cleanupscript')\n AND ep.dropped = FALSE\n )\n WHERE rn = 1\n ", "describe": { "columns": [ { "name": "workspace_id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "execution_process_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 3, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ false, true, false, false, true ] }, "hash": "3d34580933bc02a168f4a7c483460a6ad13ef72b508532f5a6cd5e53aff04a69" } ================================================ FILE: crates/db/.sqlx/query-3d85256618729c1c0bf2758ffab9cdb4ec2af0751e3a37db4009c02f95f6f556.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO merges (\n id, workspace_id, repo_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name\n ) VALUES ($1, $2, $3, 'pr', $4, $5, 'open', $6, $7)\n RETURNING\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime\",\n target_branch_name as \"target_branch_name!: String\"\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 10, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 7 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "3d85256618729c1c0bf2758ffab9cdb4ec2af0751e3a37db4009c02f95f6f556" } ================================================ FILE: crates/db/.sqlx/query-3f4b2179dcd8857fc18f1e5f6e6cf10f152eebbb141b2d3604dd4191e0c2f367.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep WHERE ep.id = ?", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "3f4b2179dcd8857fc18f1e5f6e6cf10f152eebbb141b2d3604dd4191e0c2f367" } ================================================ FILE: crates/db/.sqlx/query-420a0c490f1e4d549fb194265e971d8c03b86fe75b595091f6425de11d120a6b.json ================================================ { "db_name": "SQLite", "query": "UPDATE execution_processes\n SET dropped = TRUE\n WHERE session_id = $1\n AND created_at >= (SELECT created_at FROM execution_processes WHERE id = $2)\n AND dropped = FALSE", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "420a0c490f1e4d549fb194265e971d8c03b86fe75b595091f6425de11d120a6b" } ================================================ FILE: crates/db/.sqlx/query-4506eeeb85b49f00143a9d23636c976159e1c72c9cfce005599c5eb52dc15095.json ================================================ { "db_name": "SQLite", "query": "SELECT s.id AS \"id!: Uuid\",\n s.workspace_id AS \"workspace_id!: Uuid\",\n s.name,\n s.executor,\n s.agent_working_dir,\n s.created_at AS \"created_at!: DateTime\",\n s.updated_at AS \"updated_at!: DateTime\"\n FROM sessions s\n LEFT JOIN (\n SELECT ep.session_id, MAX(ep.created_at) as last_used\n FROM execution_processes ep\n WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\n GROUP BY ep.session_id\n ) latest_ep ON s.id = latest_ep.session_id\n WHERE s.workspace_id = $1\n ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "executor", "ordinal": 3, "type_info": "Text" }, { "name": "agent_working_dir", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, true, true, true, false, false ] }, "hash": "4506eeeb85b49f00143a9d23636c976159e1c72c9cfce005599c5eb52dc15095" } ================================================ FILE: crates/db/.sqlx/query-48c556a5317e6ea77595a8fdc410d30df50c8405adf38b371fdf0a1bde8c0083.json ================================================ { "db_name": "SQLite", "query": "UPDATE execution_process_repo_states\n SET before_head_commit = $1, updated_at = $2\n WHERE execution_process_id = $3\n AND repo_id = $4", "describe": { "columns": [], "parameters": { "Right": 4 }, "nullable": [] }, "hash": "48c556a5317e6ea77595a8fdc410d30df50c8405adf38b371fdf0a1bde8c0083" } ================================================ FILE: crates/db/.sqlx/query-4ac35216ead7e5be9cc2de504a06b6e375e23ca2ed14493ec991f53e458a6a34.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM attachments WHERE id = $1", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "4ac35216ead7e5be9cc2de504a06b6e375e23ca2ed14493ec991f53e458a6a34" } ================================================ FILE: crates/db/.sqlx/query-4b59b958807be54c7c9949d96ced96e4ab1498f1056e7d0d7956aff46352d90f.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n target_branch_name as \"target_branch_name!: String\",\n created_at as \"created_at!: DateTime\"\n FROM merges\n WHERE workspace_id = $1\n ORDER BY created_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 10, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "4b59b958807be54c7c9949d96ced96e4ab1498f1056e7d0d7956aff46352d90f" } ================================================ FILE: crates/db/.sqlx/query-4b952fb779fbcf70bd402b6bcc0eec07b75879333614b8ef98e5b8073ad66ca6.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO attachments (id, file_path, original_name, mime_type, size_bytes, hash)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", \n file_path as \"file_path!\", \n original_name as \"original_name!\", \n mime_type,\n size_bytes as \"size_bytes!\",\n hash as \"hash!\",\n created_at as \"created_at!: DateTime\", \n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 6 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "4b952fb779fbcf70bd402b6bcc0eec07b75879333614b8ef98e5b8073ad66ca6" } ================================================ FILE: crates/db/.sqlx/query-4c9b1b539ec383ace94ef29c58967bbf08112ebdc61276e9710663a083318211.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "project_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "title", "ordinal": 2, "type_info": "Text" }, { "name": "description", "ordinal": 3, "type_info": "Text" }, { "name": "status!: TaskStatus", "ordinal": 4, "type_info": "Text" }, { "name": "parent_workspace_id: Uuid", "ordinal": 5, "type_info": "Blob" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, true, false, true, false, false ] }, "hash": "4c9b1b539ec383ace94ef29c58967bbf08112ebdc61276e9710663a083318211" } ================================================ FILE: crates/db/.sqlx/query-4d84b308a2cc7677da65111d080bf02e5e35c052048360d3dbea656bbbcd3edb.json ================================================ { "db_name": "SQLite", "query": "UPDATE execution_process_repo_states\n SET merge_commit = $1, updated_at = $2\n WHERE execution_process_id = $3\n AND repo_id = $4", "describe": { "columns": [], "parameters": { "Right": 4 }, "nullable": [] }, "hash": "4d84b308a2cc7677da65111d080bf02e5e35c052048360d3dbea656bbbcd3edb" } ================================================ FILE: crates/db/.sqlx/query-4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "4d86dcbf754f971ff3acffe9e85b5c2f455e77c40c624e09736be9480238110b" } ================================================ FILE: crates/db/.sqlx/query-5154289c5a9ddbc061d42de2baf129e03a75061b8b305110921688f01d112de1.json ================================================ { "db_name": "SQLite", "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n ORDER BY w.updated_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" }, { "name": "is_running!: i64", "ordinal": 11, "type_info": "Integer" }, { "name": "is_errored!: i64", "ordinal": 12, "type_info": "Integer" } ], "parameters": { "Right": 0 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false, false, false ] }, "hash": "5154289c5a9ddbc061d42de2baf129e03a75061b8b305110921688f01d112de1" } ================================================ FILE: crates/db/.sqlx/query-517fb82570b9624e166696af53963ec499966562b23b5833fc4ca4cf43bcaccc.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM migration_state\n WHERE entity_type = $1\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "517fb82570b9624e166696af53963ec499966562b23b5833fc4ca4cf43bcaccc" } ================================================ FILE: crates/db/.sqlx/query-557963b950205b10db273762da5fd24c9db96c1f366a796c319e4adc888d7414.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspace_repos SET target_branch = $1, updated_at = datetime('now') WHERE workspace_id = $2 AND repo_id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "557963b950205b10db273762da5fd24c9db96c1f366a796c319e4adc888d7414" } ================================================ FILE: crates/db/.sqlx/query-570f62a32921c4a9a7e4e1006e9b31c4c58e69ab8681d76dfe9b184ff1e0bc65.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime\",\n target_branch_name as \"target_branch_name!: String\"\n FROM merges\n WHERE merge_type = 'pr'\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 10, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "570f62a32921c4a9a7e4e1006e9b31c4c58e69ab8681d76dfe9b184ff1e0bc65" } ================================================ FILE: crates/db/.sqlx/query-5785a10c3d51ff9b001aa455f6296a4dcba61cec700a4b72031c5c643b273938.json ================================================ { "db_name": "SQLite", "query": "UPDATE merges\n SET pr_status = $1,\n pr_merge_commit_sha = $2,\n pr_merged_at = $3\n WHERE id = $4", "describe": { "columns": [], "parameters": { "Right": 4 }, "nullable": [] }, "hash": "5785a10c3d51ff9b001aa455f6296a4dcba61cec700a4b72031c5c643b273938" } ================================================ FILE: crates/db/.sqlx/query-57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET\n archived = COALESCE($1, archived),\n pinned = COALESCE($2, pinned),\n name = CASE WHEN $3 THEN $4 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $5", "describe": { "columns": [], "parameters": { "Right": 5 }, "nullable": [] }, "hash": "57d6335c98deb608cf961836f73a67163af6484f91954c0577aea1cc2fddac4f" } ================================================ FILE: crates/db/.sqlx/query-586d5ab95899967c8a8f74996c4a598a73661823bc2bcb240cebb7cd0533abb6.json ================================================ { "db_name": "SQLite", "query": "SELECT i.id as \"id!: Uuid\",\n i.file_path as \"file_path!\",\n i.original_name as \"original_name!\",\n i.mime_type,\n i.size_bytes as \"size_bytes!\",\n i.hash as \"hash!\",\n i.created_at as \"created_at!: DateTime\",\n i.updated_at as \"updated_at!: DateTime\"\n FROM attachments i\n JOIN workspace_attachments wa ON i.id = wa.attachment_id\n WHERE wa.workspace_id = $1\n ORDER BY wa.created_at", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "586d5ab95899967c8a8f74996c4a598a73661823bc2bcb240cebb7cd0533abb6" } ================================================ FILE: crates/db/.sqlx/query-5884e7baa4a061166cb2911f717d3fd92852d62975a910dd9cb05e7908fdf8b6.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n path,\n name,\n display_name,\n setup_script,\n cleanup_script,\n archive_script,\n copy_files,\n parallel_setup_script as \"parallel_setup_script!: bool\",\n dev_server_script,\n default_target_branch,\n default_working_dir,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM repos\n WHERE name = '__NEEDS_BACKFILL__'", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "5884e7baa4a061166cb2911f717d3fd92852d62975a910dd9cb05e7908fdf8b6" } ================================================ FILE: crates/db/.sqlx/query-592656b17a5f78d117365909e47afba7d3df545ac1078c307c6b968e75c8e2ba.json ================================================ { "db_name": "SQLite", "query": "SELECT\n w.id AS \"id!: Uuid\",\n w.task_id AS \"task_id: Uuid\",\n w.container_ref,\n w.branch,\n w.setup_completed_at AS \"setup_completed_at: DateTime\",\n w.created_at AS \"created_at!: DateTime\",\n w.updated_at AS \"updated_at!: DateTime\",\n w.archived AS \"archived!: bool\",\n w.pinned AS \"pinned!: bool\",\n w.name,\n w.worktree_deleted AS \"worktree_deleted!: bool\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"is_running!: i64\",\n\n CASE WHEN (\n SELECT ep.status\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = w.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END AS \"is_errored!: i64\"\n\n FROM workspaces w\n WHERE w.id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" }, { "name": "is_running!: i64", "ordinal": 11, "type_info": "Null" }, { "name": "is_errored!: i64", "ordinal": 12, "type_info": "Null" } ], "parameters": { "Right": 1 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false, null, null ] }, "hash": "592656b17a5f78d117365909e47afba7d3df545ac1078c307c6b968e75c8e2ba" } ================================================ FILE: crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json ================================================ { "db_name": "SQLite", "query": "\n INSERT INTO scratch (id, scratch_type, payload)\n VALUES ($1, $2, $3)\n RETURNING\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "scratch_type", "ordinal": 1, "type_info": "Text" }, { "name": "payload", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ false, false, false, false, false ] }, "hash": "5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2" } ================================================ FILE: crates/db/.sqlx/query-5d9739705372b113b1bb45d441ebdf2846dc4cd83b8547128c733cd282b5b4f2.json ================================================ { "db_name": "SQLite", "query": "UPDATE execution_processes\n SET status = $1, exit_code = $2, completed_at = $3\n WHERE id = $4", "describe": { "columns": [], "parameters": { "Right": 4 }, "nullable": [] }, "hash": "5d9739705372b113b1bb45d441ebdf2846dc4cd83b8547128c733cd282b5b4f2" } ================================================ FILE: crates/db/.sqlx/query-5ff9809face43fe1f071dfda62b6a30f4a32a9aaace29caf89b95c224482201b.json ================================================ { "db_name": "SQLite", "query": "SELECT r.id as \"id!: Uuid\", r.path, r.name, r.copy_files\n FROM repos r\n JOIN workspace_repos wr ON r.id = wr.repo_id\n WHERE wr.workspace_id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "copy_files", "ordinal": 3, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true ] }, "hash": "5ff9809face43fe1f071dfda62b6a30f4a32a9aaace29caf89b95c224482201b" } ================================================ FILE: crates/db/.sqlx/query-606016bf31cf0abd87d7eb95ea99d7c8c014523cb02e482d608299ceefaeffc5.json ================================================ { "db_name": "SQLite", "query": "SELECT EXISTS(\n SELECT 1 FROM coding_agent_turns cat\n JOIN execution_processes ep ON cat.execution_process_id = ep.id\n JOIN sessions s ON ep.session_id = s.id\n WHERE s.workspace_id = $1 AND cat.seen = 0\n ) as \"has_unseen!: bool\"", "describe": { "columns": [ { "name": "has_unseen!: bool", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "606016bf31cf0abd87d7eb95ea99d7c8c014523cb02e482d608299ceefaeffc5" } ================================================ FILE: crates/db/.sqlx/query-61c6546164ba21a659d32d4e345926b0ee1a611fe4e46bb8db51a4e41f781af9.json ================================================ { "db_name": "SQLite", "query": "UPDATE coding_agent_turns\n SET summary = $1, updated_at = $2\n WHERE execution_process_id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "61c6546164ba21a659d32d4e345926b0ee1a611fe4e46bb8db51a4e41f781af9" } ================================================ FILE: crates/db/.sqlx/query-64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf.json ================================================ { "db_name": "SQLite", "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n ORDER BY created_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" } ], "parameters": { "Right": 0 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false ] }, "hash": "64f31710ab7ba14047f31cce44ad36c60a53624f9bcb03a5eaff5d61ca8cc9cf" } ================================================ FILE: crates/db/.sqlx/query-686810c5271e1d44042b6ea2c6cc434eb2e3f5d3540c97d703c34dd4e978c690.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM migration_state\n WHERE entity_type = $1 AND local_id = $2", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "686810c5271e1d44042b6ea2c6cc434eb2e3f5d3540c97d703c34dd4e978c690" } ================================================ FILE: crates/db/.sqlx/query-6ae5eb2719382d4d081ee17dbd5de654c156b06e2af4ddfb917d36002146be5b.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO workspace_attachments (id, workspace_id, attachment_id)\n SELECT $1, $2, $3\n WHERE NOT EXISTS (\n SELECT 1 FROM workspace_attachments WHERE workspace_id = $2 AND attachment_id = $3\n )", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "6ae5eb2719382d4d081ee17dbd5de654c156b06e2af4ddfb917d36002146be5b" } ================================================ FILE: crates/db/.sqlx/query-70b21c5c0a2ba5c21c9c1132f14a68c02a8a2cd555caea74e57a0aeb206770d3.json ================================================ { "db_name": "SQLite", "query": "SELECT COUNT(*) as \"count!: i64\" FROM workspaces", "describe": { "columns": [ { "name": "count!: i64", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 0 }, "nullable": [ false ] }, "hash": "70b21c5c0a2ba5c21c9c1132f14a68c02a8a2cd555caea74e57a0aeb206770d3" } ================================================ FILE: crates/db/.sqlx/query-7364150098bec681451c43762117a1f5c5b4e27f5f65186c3cc16092b3491c37.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO migration_state (id, entity_type, local_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (entity_type, local_id) DO UPDATE SET\n updated_at = datetime('now', 'subsec')\n RETURNING\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "7364150098bec681451c43762117a1f5c5b4e27f5f65186c3cc16092b3491c37" } ================================================ FILE: crates/db/.sqlx/query-73aee4cb95294087554eafaf3126556df244f4b6639d5a188f0badb6739c1a70.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tags\n ORDER BY tag_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "tag_name", "ordinal": 1, "type_info": "Text" }, { "name": "content!", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, false ] }, "hash": "73aee4cb95294087554eafaf3126556df244f4b6639d5a188f0badb6739c1a70" } ================================================ FILE: crates/db/.sqlx/query-7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id as \"id!: Uuid\", task_id as \"task_id: Uuid\", container_ref, branch, setup_completed_at as \"setup_completed_at: DateTime\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\", archived as \"archived!: bool\", pinned as \"pinned!: bool\", name, worktree_deleted as \"worktree_deleted!: bool\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" } ], "parameters": { "Right": 6 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false ] }, "hash": "7410e8128e63af1c3127e833accee637e65f7efcd9111ecb891587294042129c" } ================================================ FILE: crates/db/.sqlx/query-766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1.json ================================================ { "db_name": "SQLite", "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" } ], "parameters": { "Right": 1 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false ] }, "hash": "766fa107de23b7e6c579223b083d916e252d422e2908c27f6718fcbd851de2c1" } ================================================ FILE: crates/db/.sqlx/query-7807cb09da72c5a1e35bf4f4da1bea1743a578588e72444ede98f5f969af08c1.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM migration_state\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "7807cb09da72c5a1e35bf4f4da1bea1743a578588e72444ede98f5f969af08c1" } ================================================ FILE: crates/db/.sqlx/query-784e59a5259f046a74bbfd3cc5a78500797ccf3e67928e5f1520623c5c04ac9f.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO workspace_repos (id, workspace_id, repo_id, target_branch)\n VALUES ($1, $2, $3, $4)\n RETURNING id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n target_branch,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "target_branch", "ordinal": 3, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 5, "type_info": "Text" } ], "parameters": { "Right": 4 }, "nullable": [ true, false, false, false, false, false ] }, "hash": "784e59a5259f046a74bbfd3cc5a78500797ccf3e67928e5f1520623c5c04ac9f" } ================================================ FILE: crates/db/.sqlx/query-79e1e11b83c786c6d5a985ab045b6bd122d5efa920225dadc9fedb6592c6e0a3.json ================================================ { "db_name": "SQLite", "query": "UPDATE execution_process_repo_states\n SET after_head_commit = $1, updated_at = $2\n WHERE execution_process_id = $3\n AND repo_id = $4", "describe": { "columns": [], "parameters": { "Right": 4 }, "nullable": [] }, "hash": "79e1e11b83c786c6d5a985ab045b6bd122d5efa920225dadc9fedb6592c6e0a3" } ================================================ FILE: crates/db/.sqlx/query-79f6b9f999c33900ae87475d72651b274cc94ab3b1f36e9c5517bc5572ea9947.json ================================================ { "db_name": "SQLite", "query": "UPDATE repos\n SET display_name = $1,\n setup_script = $2,\n cleanup_script = $3,\n archive_script = $4,\n copy_files = $5,\n parallel_setup_script = $6,\n dev_server_script = $7,\n default_target_branch = $8,\n default_working_dir = $9,\n updated_at = datetime('now', 'subsec')\n WHERE id = $10\n RETURNING id as \"id!: Uuid\",\n path,\n name,\n display_name,\n setup_script,\n cleanup_script,\n archive_script,\n copy_files,\n parallel_setup_script as \"parallel_setup_script!: bool\",\n dev_server_script,\n default_target_branch,\n default_working_dir,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 10 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "79f6b9f999c33900ae87475d72651b274cc94ab3b1f36e9c5517bc5572ea9947" } ================================================ FILE: crates/db/.sqlx/query-7d12bf106e68365fc1aa239b8b39065430f30ad658d0bf9801c81e3ced2127da.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep WHERE ep.rowid = ?", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "7d12bf106e68365fc1aa239b8b39065430f30ad658d0bf9801c81e3ced2127da" } ================================================ FILE: crates/db/.sqlx/query-80669005bff96b45015f095ccf28598df604540e2aaf3828fcb8db7d55538dc7.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO tags (id, tag_name, content)\n VALUES ($1, $2, $3)\n RETURNING id as \"id!: Uuid\", tag_name, content as \"content!\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "tag_name", "ordinal": 1, "type_info": "Text" }, { "name": "content!", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ true, false, false, false, false ] }, "hash": "80669005bff96b45015f095ccf28598df604540e2aaf3828fcb8db7d55538dc7" } ================================================ FILE: crates/db/.sqlx/query-80e6cfb4e27fcaa79b7dbd37ac16aac255f46a646c75aa65111b2f58ec03f892.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep WHERE ep.status = 'running' ORDER BY ep.created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "80e6cfb4e27fcaa79b7dbd37ac16aac255f46a646c75aa65111b2f58ec03f892" } ================================================ FILE: crates/db/.sqlx/query-82b92577d15b92908cecca7bff6ff21478c90a16eb6123165f0bd16d2d60bca4.json ================================================ { "db_name": "SQLite", "query": "SELECT DISTINCT s.workspace_id as \"workspace_id!: Uuid\"\n FROM coding_agent_turns cat\n JOIN execution_processes ep ON cat.execution_process_id = ep.id\n JOIN sessions s ON ep.session_id = s.id\n JOIN workspaces w ON s.workspace_id = w.id\n WHERE cat.seen = 0 AND w.archived = $1", "describe": { "columns": [ { "name": "workspace_id!: Uuid", "ordinal": 0, "type_info": "Blob" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "82b92577d15b92908cecca7bff6ff21478c90a16eb6123165f0bd16d2d60bca4" } ================================================ FILE: crates/db/.sqlx/query-82f7bba858a26732ad9d4122c3a0bca4209ae37c59dcd7353a68e2dec434c48a.json ================================================ { "db_name": "SQLite", "query": "SELECT r.id as \"id!: Uuid\",\n r.path,\n r.name,\n r.display_name,\n r.setup_script,\n r.cleanup_script,\n r.archive_script,\n r.copy_files,\n r.parallel_setup_script as \"parallel_setup_script!: bool\",\n r.dev_server_script,\n r.default_target_branch,\n r.default_working_dir,\n r.created_at as \"created_at!: DateTime\",\n r.updated_at as \"updated_at!: DateTime\"\n FROM repos r\n JOIN workspace_repos wr ON r.id = wr.repo_id\n WHERE wr.workspace_id = $1\n ORDER BY r.display_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "82f7bba858a26732ad9d4122c3a0bca4209ae37c59dcd7353a68e2dec434c48a" } ================================================ FILE: crates/db/.sqlx/query-84ee994f0aad005cf62ca318eb20ae29d218a72cdd1fadf2a5ae399b0719ca19.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM migration_state\n WHERE status = $1\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "84ee994f0aad005cf62ca318eb20ae29d218a72cdd1fadf2a5ae399b0719ca19" } ================================================ FILE: crates/db/.sqlx/query-891bad4b14f75be20c70a5cec02fb3b4fb3cbb84ce322bf5da3791d75b1deae7.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n path,\n name,\n display_name,\n setup_script,\n cleanup_script,\n archive_script,\n copy_files,\n parallel_setup_script as \"parallel_setup_script!: bool\",\n dev_server_script,\n default_target_branch,\n default_working_dir,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM repos\n ORDER BY display_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "891bad4b14f75be20c70a5cec02fb3b4fb3cbb84ce322bf5da3791d75b1deae7" } ================================================ FILE: crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM scratch WHERE id = $1 AND scratch_type = $2", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8" } ================================================ FILE: crates/db/.sqlx/query-9139f8d02c4ff94db0f2e8de7a6d5a53092479499815531962b7c84f5e0b2129.json ================================================ { "db_name": "SQLite", "query": "SELECT logs\n FROM execution_process_logs \n WHERE execution_id = $1\n ORDER BY inserted_at ASC", "describe": { "columns": [ { "name": "logs", "ordinal": 0, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "9139f8d02c4ff94db0f2e8de7a6d5a53092479499815531962b7c84f5e0b2129" } ================================================ FILE: crates/db/.sqlx/query-91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n w.id as \"id!: Uuid\",\n w.task_id as \"task_id: Uuid\",\n w.container_ref,\n w.branch as \"branch!\",\n w.setup_completed_at as \"setup_completed_at: DateTime\",\n w.created_at as \"created_at!: DateTime\",\n w.updated_at as \"updated_at!: DateTime\",\n w.archived as \"archived!: bool\",\n w.pinned as \"pinned!: bool\",\n w.name,\n w.worktree_deleted as \"worktree_deleted!: bool\"\n FROM workspaces w\n LEFT JOIN sessions s ON w.id = s.workspace_id\n LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL\n WHERE w.container_ref IS NOT NULL\n AND w.worktree_deleted = FALSE\n AND w.id NOT IN (\n SELECT DISTINCT s2.workspace_id\n FROM sessions s2\n JOIN execution_processes ep2 ON s2.id = ep2.session_id\n WHERE ep2.completed_at IS NULL\n )\n GROUP BY w.id, w.container_ref, w.updated_at\n HAVING datetime('now', 'localtime',\n CASE\n WHEN w.archived = 1\n THEN '-1 hours'\n ELSE '-72 hours'\n END\n ) > datetime(\n MAX(\n max(\n datetime(w.updated_at),\n datetime(ep.completed_at)\n )\n )\n )\n ORDER BY MAX(\n CASE\n WHEN ep.completed_at IS NOT NULL THEN ep.completed_at\n ELSE w.updated_at\n END\n ) ASC\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch!", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" } ], "parameters": { "Right": 0 }, "nullable": [ true, true, true, true, true, true, true, true, true, true, true ] }, "hash": "91810eeed4804827717a182ad1b61c641648e2659100f43ef9504fc60e5d244e" } ================================================ FILE: crates/db/.sqlx/query-93efe07b91d232fc0c371be8ee618ba6ccfd6930454fc11845d5dfc2ba0bad62.json ================================================ { "db_name": "SQLite", "query": "SELECT s.id AS \"id!: Uuid\",\n s.workspace_id AS \"workspace_id!: Uuid\",\n s.name,\n s.executor,\n s.agent_working_dir,\n s.created_at AS \"created_at!: DateTime\",\n s.updated_at AS \"updated_at!: DateTime\"\n FROM sessions s\n LEFT JOIN (\n SELECT ep.session_id, MAX(ep.created_at) as last_used\n FROM execution_processes ep\n WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE\n GROUP BY ep.session_id\n ) latest_ep ON s.id = latest_ep.session_id\n WHERE s.workspace_id = $1\n ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC\n LIMIT 1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "executor", "ordinal": 3, "type_info": "Text" }, { "name": "agent_working_dir", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, true, true, true, false, false ] }, "hash": "93efe07b91d232fc0c371be8ee618ba6ccfd6930454fc11845d5dfc2ba0bad62" } ================================================ FILE: crates/db/.sqlx/query-973e43902b05d671f69b24a0aeeb07bc0cbcd22d75b20c83c49a122f92c6b231.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n path,\n name,\n display_name,\n setup_script,\n cleanup_script,\n archive_script,\n copy_files,\n parallel_setup_script as \"parallel_setup_script!: bool\",\n dev_server_script,\n default_target_branch,\n default_working_dir,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM repos\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "973e43902b05d671f69b24a0aeeb07bc0cbcd22d75b20c83c49a122f92c6b231" } ================================================ FILE: crates/db/.sqlx/query-9747ebaebd562d65f0c333b0f5efc74fa63ab9fcb35a43f75f57da3fcb9a2588.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO execution_process_logs (execution_id, logs, byte_size, inserted_at)\n VALUES ($1, $2, $3, datetime('now', 'subsec'))", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "9747ebaebd562d65f0c333b0f5efc74fa63ab9fcb35a43f75f57da3fcb9a2588" } ================================================ FILE: crates/db/.sqlx/query-9821ee63362e96cf3fd936e2d54a641fb30f239a8137dc6c1b3a670b2c6138c1.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n target_branch,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM workspace_repos\n WHERE workspace_id = $1 AND repo_id = $2", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "target_branch", "ordinal": 3, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 5, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, false, false, false ] }, "hash": "9821ee63362e96cf3fd936e2d54a641fb30f239a8137dc6c1b3a670b2c6138c1" } ================================================ FILE: crates/db/.sqlx/query-99399425f53b140a8de232a4de3c6c056bc422f2fbdb8ead6aab3f6945906e51.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n s.workspace_id as \"workspace_id!: Uuid\",\n eprs.repo_id as \"repo_id!: Uuid\",\n eprs.after_head_commit as after_head_commit,\n prev.after_head_commit as prev_after_head_commit,\n wr.target_branch as \"target_branch!\",\n r.path as repo_path\n FROM execution_processes ep\n JOIN sessions s ON s.id = ep.session_id\n JOIN execution_process_repo_states eprs ON eprs.execution_process_id = ep.id\n JOIN repos r ON r.id = eprs.repo_id\n JOIN workspaces w ON w.id = s.workspace_id\n JOIN workspace_repos wr ON wr.workspace_id = w.id AND wr.repo_id = eprs.repo_id\n LEFT JOIN execution_process_repo_states prev\n ON prev.execution_process_id = (\n SELECT id FROM execution_processes\n WHERE session_id = ep.session_id\n AND created_at < ep.created_at\n ORDER BY created_at DESC\n LIMIT 1\n )\n AND prev.repo_id = eprs.repo_id\n WHERE eprs.before_head_commit IS NULL\n AND eprs.after_head_commit IS NOT NULL", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 3, "type_info": "Blob" }, { "name": "after_head_commit", "ordinal": 4, "type_info": "Text" }, { "name": "prev_after_head_commit", "ordinal": 5, "type_info": "Text" }, { "name": "target_branch!", "ordinal": 6, "type_info": "Text" }, { "name": "repo_path", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, false, false ] }, "hash": "99399425f53b140a8de232a4de3c6c056bc422f2fbdb8ead6aab3f6945906e51" } ================================================ FILE: crates/db/.sqlx/query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json ================================================ { "db_name": "SQLite", "query": "UPDATE projects\n SET remote_project_id = $2\n WHERE id = $1", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb" } ================================================ FILE: crates/db/.sqlx/query-9f783d1b275548a59429235991e5299b7aaf071effebbd62f006404b3ce83dc8.json ================================================ { "db_name": "SQLite", "query": "SELECT r.id as \"id!: Uuid\",\n r.path,\n r.name,\n r.display_name,\n r.setup_script,\n r.cleanup_script,\n r.archive_script,\n r.copy_files,\n r.parallel_setup_script as \"parallel_setup_script!: bool\",\n r.dev_server_script,\n r.default_target_branch,\n r.default_working_dir,\n r.created_at as \"created_at!: DateTime\",\n r.updated_at as \"updated_at!: DateTime\",\n wr.target_branch\n FROM repos r\n JOIN workspace_repos wr ON r.id = wr.repo_id\n WHERE wr.workspace_id = $1\n ORDER BY r.display_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" }, { "name": "target_branch", "ordinal": 14, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false, false ] }, "hash": "9f783d1b275548a59429235991e5299b7aaf071effebbd62f006404b3ce83dc8" } ================================================ FILE: crates/db/.sqlx/query-9f8ab7d7c2660321412117bfb55e3b2b9ccd7b9ed2679fb8ccca0a36996e6e21.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO execution_process_repo_states (\n id,\n execution_process_id,\n repo_id,\n before_head_commit,\n after_head_commit,\n merge_commit,\n created_at,\n updated_at\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { "Right": 8 }, "nullable": [] }, "hash": "9f8ab7d7c2660321412117bfb55e3b2b9ccd7b9ed2679fb8ccca0a36996e6e21" } ================================================ FILE: crates/db/.sqlx/query-a1574f21db387b0e4a2c3f5723de6df4ee42d98145d16e9d135345dd60128429.json ================================================ { "db_name": "SQLite", "query": "SELECT \n execution_id as \"execution_id!: Uuid\",\n logs,\n byte_size,\n inserted_at as \"inserted_at!: DateTime\"\n FROM execution_process_logs \n WHERE execution_id = $1\n ORDER BY inserted_at ASC", "describe": { "columns": [ { "name": "execution_id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "logs", "ordinal": 1, "type_info": "Text" }, { "name": "byte_size", "ordinal": 2, "type_info": "Integer" }, { "name": "inserted_at!: DateTime", "ordinal": 3, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ false, false, false, false ] }, "hash": "a1574f21db387b0e4a2c3f5723de6df4ee42d98145d16e9d135345dd60128429" } ================================================ FILE: crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json ================================================ { "db_name": "SQLite", "query": "\n INSERT INTO scratch (id, scratch_type, payload)\n VALUES ($1, $2, $3)\n ON CONFLICT(id, scratch_type) DO UPDATE SET\n payload = excluded.payload,\n updated_at = datetime('now', 'subsec')\n RETURNING\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "scratch_type", "ordinal": 1, "type_info": "Text" }, { "name": "payload", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ false, false, false, false, false ] }, "hash": "a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856" } ================================================ FILE: crates/db/.sqlx/query-a4a50fcfb903e6d0a315676f4f760e5bb7718e10ea550aedf990c9da84834416.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM migration_state", "describe": { "columns": [], "parameters": { "Right": 0 }, "nullable": [] }, "hash": "a4a50fcfb903e6d0a315676f4f760e5bb7718e10ea550aedf990c9da84834416" } ================================================ FILE: crates/db/.sqlx/query-a915c22f5ed0bb86c3a242ca38cbc1bfca40ebfe9096058c27e94479b67c7c02.json ================================================ { "db_name": "SQLite", "query": "UPDATE migration_state\n SET status = 'failed',\n error_message = $3,\n attempt_count = attempt_count + 1,\n updated_at = datetime('now', 'subsec')\n WHERE entity_type = $1 AND local_id = $2", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "a915c22f5ed0bb86c3a242ca38cbc1bfca40ebfe9096058c27e94479b67c7c02" } ================================================ FILE: crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "scratch_type", "ordinal": 1, "type_info": "Text" }, { "name": "payload", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ false, false, false, false, false ] }, "hash": "a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623" } ================================================ FILE: crates/db/.sqlx/query-aa598f6943fbf773ca00deb113f3955bdf689d1c22df63849bc5ce36c7c76382.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET container_ref = $1, updated_at = $2 WHERE id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "aa598f6943fbf773ca00deb113f3955bdf689d1c22df63849bc5ce36c7c76382" } ================================================ FILE: crates/db/.sqlx/query-ab2693e557142354564a2882f3c321b350828419c440885c0f88840079b1c94e.json ================================================ { "db_name": "SQLite", "query": "UPDATE coding_agent_turns\n SET seen = 1, updated_at = $1\n WHERE execution_process_id IN (\n SELECT ep.id FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n WHERE s.workspace_id = $2\n ) AND seen = 0", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "ab2693e557142354564a2882f3c321b350828419c440885c0f88840079b1c94e" } ================================================ FILE: crates/db/.sqlx/query-abbda92ba42bea0f7d17d0945d51b011bf50e7b36ee50ed74988e053f6fb0eec.json ================================================ { "db_name": "SQLite", "query": "UPDATE sessions SET\n name = CASE WHEN $1 THEN $2 ELSE name END,\n updated_at = datetime('now', 'subsec')\n WHERE id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "abbda92ba42bea0f7d17d0945d51b011bf50e7b36ee50ed74988e053f6fb0eec" } ================================================ FILE: crates/db/.sqlx/query-abff188fc81caf44081e2053cb7841d1dc6c1a8965f4b862caa2f9cebcae0176.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n file_path as \"file_path!\",\n original_name as \"original_name!\",\n mime_type,\n size_bytes as \"size_bytes!\",\n hash as \"hash!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM attachments\n WHERE file_path = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "abff188fc81caf44081e2053cb7841d1dc6c1a8965f4b862caa2f9cebcae0176" } ================================================ FILE: crates/db/.sqlx/query-b1edd509d00577007680243589ce59570182b98a1e9059d9702d97e9eaa9cbf5.json ================================================ { "db_name": "SQLite", "query": "SELECT COUNT(*) as \"count!: i64\"\n FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n WHERE s.workspace_id = $1\n AND ep.status = 'running'\n AND ep.run_reason != 'devserver'", "describe": { "columns": [ { "name": "count!: i64", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "b1edd509d00577007680243589ce59570182b98a1e9059d9702d97e9eaa9cbf5" } ================================================ FILE: crates/db/.sqlx/query-b4476bedb8e5c0f7bc21654dc62fb00fbd8a41e24efaba55be8278031d71cc59.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO merges (\n id, workspace_id, repo_id, merge_type, merge_commit, created_at, target_branch_name\n ) VALUES ($1, $2, $3, 'direct', $4, $5, $6)\n RETURNING\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime\",\n target_branch_name as \"target_branch_name!: String\"\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 10, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 6 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "b4476bedb8e5c0f7bc21654dc62fb00fbd8a41e24efaba55be8278031d71cc59" } ================================================ FILE: crates/db/.sqlx/query-b4ff8dabb0d5c99319fad3f2f7e620523c96b89beaf1edd97f79d9972b93c8fe.json ================================================ { "db_name": "SQLite", "query": "UPDATE coding_agent_turns\n SET agent_session_id = $1, updated_at = $2\n WHERE execution_process_id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "b4ff8dabb0d5c99319fad3f2f7e620523c96b89beaf1edd97f79d9972b93c8fe" } ================================================ FILE: crates/db/.sqlx/query-c097fa44c48e55f0e74f56577c0c1c4b3b92b2875d12c0bd1a70a1dcc4eda58e.json ================================================ { "db_name": "SQLite", "query": "SELECT EXISTS(SELECT 1 FROM workspaces WHERE container_ref = ?) as \"exists!: bool\"", "describe": { "columns": [ { "name": "exists!: bool", "ordinal": 0, "type_info": "Integer" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "c097fa44c48e55f0e74f56577c0c1c4b3b92b2875d12c0bd1a70a1dcc4eda58e" } ================================================ FILE: crates/db/.sqlx/query-c24119a35ed2099b886a0b1a9a41adf01d1a1f86792abf3d3a410c6cbab2ec0f.json ================================================ { "db_name": "SQLite", "query": "SELECT r.id as \"id!: Uuid\",\n r.path,\n r.name,\n r.display_name,\n r.setup_script,\n r.cleanup_script,\n r.archive_script,\n r.copy_files,\n r.parallel_setup_script as \"parallel_setup_script!: bool\",\n r.dev_server_script,\n r.default_target_branch,\n r.default_working_dir,\n r.created_at as \"created_at!: DateTime\",\n r.updated_at as \"updated_at!: DateTime\"\n FROM repos r\n LEFT JOIN (\n SELECT repo_id, MAX(updated_at) AS last_used_at\n FROM workspace_repos\n GROUP BY repo_id\n ) wr ON wr.repo_id = r.id\n ORDER BY wr.last_used_at DESC, r.display_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "c24119a35ed2099b886a0b1a9a41adf01d1a1f86792abf3d3a410c6cbab2ec0f" } ================================================ FILE: crates/db/.sqlx/query-c27f2fd6b3696cb5a8ec54226608440786a6cec601783f797be3a8c515080d62.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM repos\n WHERE id NOT IN (SELECT repo_id FROM project_repos)\n AND id NOT IN (SELECT repo_id FROM workspace_repos)", "describe": { "columns": [], "parameters": { "Right": 0 }, "nullable": [] }, "hash": "c27f2fd6b3696cb5a8ec54226608440786a6cec601783f797be3a8c515080d62" } ================================================ FILE: crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n WHERE id = $1 AND scratch_type = $2\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "scratch_type", "ordinal": 1, "type_info": "Text" }, { "name": "payload", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ false, false, false, false, false ] }, "hash": "c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39" } ================================================ FILE: crates/db/.sqlx/query-c5a45e39543468b57c2e3662735c640210c3948113dcbd1be8339f2c27506b76.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET branch = $1, updated_at = datetime('now') WHERE id = $2", "describe": { "columns": [], "parameters": { "Right": 2 }, "nullable": [] }, "hash": "c5a45e39543468b57c2e3662735c640210c3948113dcbd1be8339f2c27506b76" } ================================================ FILE: crates/db/.sqlx/query-c793ee8493c54ea295a62a51650d00894fdad2f2cadc5665ae1e16a605626cb2.json ================================================ { "db_name": "SQLite", "query": "SELECT remote_id as \"remote_id: Uuid\"\n FROM migration_state\n WHERE entity_type = $1 AND local_id = $2 AND status = 'migrated'", "describe": { "columns": [ { "name": "remote_id: Uuid", "ordinal": 0, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true ] }, "hash": "c793ee8493c54ea295a62a51650d00894fdad2f2cadc5665ae1e16a605626cb2" } ================================================ FILE: crates/db/.sqlx/query-cac90f2884c7c0eed4d2ab621016a5bc62dfbcb65539eb4a52e3306f96c0698a.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO sessions (id, workspace_id, name, executor, agent_working_dir)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id AS \"id!: Uuid\",\n workspace_id AS \"workspace_id!: Uuid\",\n name,\n executor,\n agent_working_dir,\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "executor", "ordinal": 3, "type_info": "Text" }, { "name": "agent_working_dir", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" } ], "parameters": { "Right": 5 }, "nullable": [ true, false, true, true, true, false, false ] }, "hash": "cac90f2884c7c0eed4d2ab621016a5bc62dfbcb65539eb4a52e3306f96c0698a" } ================================================ FILE: crates/db/.sqlx/query-cd6d7ca74442a100d9caf170ac43118795226f50b8392069b47abd4f7564c135.json ================================================ { "db_name": "SQLite", "query": "SELECT\n COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as \"pending!: i64\",\n COALESCE(SUM(CASE WHEN status = 'migrated' THEN 1 ELSE 0 END), 0) as \"migrated!: i64\",\n COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as \"failed!: i64\",\n COALESCE(SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END), 0) as \"skipped!: i64\",\n COUNT(*) as \"total!: i64\"\n FROM migration_state", "describe": { "columns": [ { "name": "pending!: i64", "ordinal": 0, "type_info": "Integer" }, { "name": "migrated!: i64", "ordinal": 1, "type_info": "Integer" }, { "name": "failed!: i64", "ordinal": 2, "type_info": "Integer" }, { "name": "skipped!: i64", "ordinal": 3, "type_info": "Integer" }, { "name": "total!: i64", "ordinal": 4, "type_info": "Integer" } ], "parameters": { "Right": 0 }, "nullable": [ false, false, false, false, false ] }, "hash": "cd6d7ca74442a100d9caf170ac43118795226f50b8392069b47abd4f7564c135" } ================================================ FILE: crates/db/.sqlx/query-d41acd2bd3c805f9787c0d468a25ce62bfa8b268131c19b83fd76acb59a8c9ea.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET worktree_deleted = FALSE, updated_at = datetime('now') WHERE id = ?", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "d41acd2bd3c805f9787c0d468a25ce62bfa8b268131c19b83fd76acb59a8c9ea" } ================================================ FILE: crates/db/.sqlx/query-d7a11078522c029b71a75f4a45abc941536d3ce08d8ee0fcbde3eacf6360b7d5.json ================================================ { "db_name": "SQLite", "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep\n WHERE ep.session_id = ?\n AND (? OR ep.dropped = FALSE)\n ORDER BY ep.created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "session_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "run_reason!: ExecutionProcessRunReason", "ordinal": 2, "type_info": "Text" }, { "name": "executor_action!: sqlx::types::Json", "ordinal": 3, "type_info": "Text" }, { "name": "status!: ExecutionProcessStatus", "ordinal": 4, "type_info": "Text" }, { "name": "exit_code", "ordinal": 5, "type_info": "Integer" }, { "name": "dropped!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "started_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "completed_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 9, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 10, "type_info": "Text" } ], "parameters": { "Right": 2 }, "nullable": [ true, false, false, false, false, true, false, false, true, false, false ] }, "hash": "d7a11078522c029b71a75f4a45abc941536d3ce08d8ee0fcbde3eacf6360b7d5" } ================================================ FILE: crates/db/.sqlx/query-d9f7205a1a749c23c928ef2861783446bd99a7b7939929dee1f8a409bb99ab04.json ================================================ { "db_name": "SQLite", "query": "\n SELECT DISTINCT s.workspace_id as \"workspace_id!: Uuid\"\n FROM execution_processes ep\n JOIN sessions s ON ep.session_id = s.id\n JOIN workspaces w ON s.workspace_id = w.id\n WHERE w.archived = $1\n AND ep.status = 'running'\n AND ep.run_reason = 'devserver'\n ", "describe": { "columns": [ { "name": "workspace_id!: Uuid", "ordinal": 0, "type_info": "Blob" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "d9f7205a1a749c23c928ef2861783446bd99a7b7939929dee1f8a409bb99ab04" } ================================================ FILE: crates/db/.sqlx/query-db1b29e1ea843ee4024c914820978a558f0ac4cc65da76645ccff4748240e565.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO coding_agent_turns (\n id, execution_process_id, agent_session_id, agent_message_id, prompt, summary, seen,\n created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING\n id as \"id!: Uuid\",\n execution_process_id as \"execution_process_id!: Uuid\",\n agent_session_id,\n agent_message_id,\n prompt,\n summary,\n seen as \"seen!: bool\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "execution_process_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "agent_session_id", "ordinal": 2, "type_info": "Text" }, { "name": "agent_message_id", "ordinal": 3, "type_info": "Text" }, { "name": "prompt", "ordinal": 4, "type_info": "Text" }, { "name": "summary", "ordinal": 5, "type_info": "Text" }, { "name": "seen!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 9 }, "nullable": [ true, false, true, true, true, true, false, false, false ] }, "hash": "db1b29e1ea843ee4024c914820978a558f0ac4cc65da76645ccff4748240e565" } ================================================ FILE: crates/db/.sqlx/query-db39f7ab7391c1289299e7f8aa7e1f642874eed0179e91a9558f9df534db797c.json ================================================ { "db_name": "SQLite", "query": "SELECT id AS \"id!: Uuid\",\n workspace_id AS \"workspace_id!: Uuid\",\n name,\n executor,\n agent_working_dir,\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM sessions\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "executor", "ordinal": 3, "type_info": "Text" }, { "name": "agent_working_dir", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, true, true, true, false, false ] }, "hash": "db39f7ab7391c1289299e7f8aa7e1f642874eed0179e91a9558f9df534db797c" } ================================================ FILE: crates/db/.sqlx/query-dc5d0ad507cbd962235c9e85c3e43f34c7c38eb2e08ab7899073010a6e77b37d.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\",\n file_path as \"file_path!\",\n original_name as \"original_name!\",\n mime_type,\n size_bytes as \"size_bytes!\",\n hash as \"hash!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM attachments\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "dc5d0ad507cbd962235c9e85c3e43f34c7c38eb2e08ab7899073010a6e77b37d" } ================================================ FILE: crates/db/.sqlx/query-dc88d70bb25b6437580480c346ed29fb90115e3b83fa36d8966b62f02990b9c7.json ================================================ { "db_name": "SQLite", "query": "UPDATE coding_agent_turns\n SET agent_message_id = $1, updated_at = $2\n WHERE execution_process_id = $3", "describe": { "columns": [], "parameters": { "Right": 3 }, "nullable": [] }, "hash": "dc88d70bb25b6437580480c346ed29fb90115e3b83fa36d8966b62f02990b9c7" } ================================================ FILE: crates/db/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json ================================================ { "db_name": "SQLite", "query": "DELETE FROM tags WHERE id = $1", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824" } ================================================ FILE: crates/db/.sqlx/query-df2f35912a8055dff6cb24c83ea67fc49b432f457961fa584c6a13389bfdcea5.json ================================================ { "db_name": "SQLite", "query": "SELECT i.id as \"id!: Uuid\",\n i.file_path as \"file_path!\",\n i.original_name as \"original_name!\",\n i.mime_type,\n i.size_bytes as \"size_bytes!\",\n i.hash as \"hash!\",\n i.created_at as \"created_at!: DateTime\",\n i.updated_at as \"updated_at!: DateTime\"\n FROM attachments i\n LEFT JOIN workspace_attachments wa ON i.id = wa.attachment_id\n WHERE wa.workspace_id IS NULL", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "file_path!", "ordinal": 1, "type_info": "Text" }, { "name": "original_name!", "ordinal": 2, "type_info": "Text" }, { "name": "mime_type", "ordinal": 3, "type_info": "Text" }, { "name": "size_bytes!", "ordinal": 4, "type_info": "Integer" }, { "name": "hash!", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, true, true, false, false, false ] }, "hash": "df2f35912a8055dff6cb24c83ea67fc49b432f457961fa584c6a13389bfdcea5" } ================================================ FILE: crates/db/.sqlx/query-df66eae37a24c07c2ae0a521c802e3828ac153e6c087edcf2ba4dbe621dc79d3.json ================================================ { "db_name": "SQLite", "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_workspace_id as \"parent_workspace_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks\n WHERE id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "project_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "title", "ordinal": 2, "type_info": "Text" }, { "name": "description", "ordinal": 3, "type_info": "Text" }, { "name": "status!: TaskStatus", "ordinal": 4, "type_info": "Text" }, { "name": "parent_workspace_id: Uuid", "ordinal": 5, "type_info": "Blob" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, false, true, false, false ] }, "hash": "df66eae37a24c07c2ae0a521c802e3828ac153e6c087edcf2ba4dbe621dc79d3" } ================================================ FILE: crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json ================================================ { "db_name": "SQLite", "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n WHERE rowid = $1\n ", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "scratch_type", "ordinal": 1, "type_info": "Text" }, { "name": "payload", "ordinal": 2, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 3, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 4, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ false, false, false, false, false ] }, "hash": "e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd" } ================================================ FILE: crates/db/.sqlx/query-ee06dfd8dc7fc2ffc239db9635a3a5cac2e603992392a632bff7d450c6bca061.json ================================================ { "db_name": "SQLite", "query": "INSERT INTO migration_state (id, entity_type, local_id)\n VALUES ($1, $2, $3)\n RETURNING\n id as \"id!: Uuid\",\n entity_type as \"entity_type!: EntityType\",\n local_id as \"local_id!: Uuid\",\n remote_id as \"remote_id: Uuid\",\n status as \"status!: MigrationStatus\",\n error_message,\n attempt_count as \"attempt_count!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Text" }, { "name": "entity_type!: EntityType", "ordinal": 1, "type_info": "Text" }, { "name": "local_id!: Uuid", "ordinal": 2, "type_info": "Text" }, { "name": "remote_id: Uuid", "ordinal": 3, "type_info": "Text" }, { "name": "status!: MigrationStatus", "ordinal": 4, "type_info": "Text" }, { "name": "error_message", "ordinal": 5, "type_info": "Text" }, { "name": "attempt_count!", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 3 }, "nullable": [ true, false, false, true, false, true, false, false, false ] }, "hash": "ee06dfd8dc7fc2ffc239db9635a3a5cac2e603992392a632bff7d450c6bca061" } ================================================ FILE: crates/db/.sqlx/query-efce74898a8e81dafc3933231e8ac3c07be392e1c073e62c621138c00d0ed30d.json ================================================ { "db_name": "SQLite", "query": "UPDATE workspaces SET worktree_deleted = TRUE, updated_at = datetime('now') WHERE id = ?", "describe": { "columns": [], "parameters": { "Right": 1 }, "nullable": [] }, "hash": "efce74898a8e81dafc3933231e8ac3c07be392e1c073e62c621138c00d0ed30d" } ================================================ FILE: crates/db/.sqlx/query-f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d.json ================================================ { "db_name": "SQLite", "query": "SELECT id AS \"id!: Uuid\",\n task_id AS \"task_id: Uuid\",\n container_ref,\n branch,\n setup_completed_at AS \"setup_completed_at: DateTime\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\",\n archived AS \"archived!: bool\",\n pinned AS \"pinned!: bool\",\n name,\n worktree_deleted AS \"worktree_deleted!: bool\"\n FROM workspaces\n WHERE rowid = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "task_id: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "container_ref", "ordinal": 2, "type_info": "Text" }, { "name": "branch", "ordinal": 3, "type_info": "Text" }, { "name": "setup_completed_at: DateTime", "ordinal": 4, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "archived!: bool", "ordinal": 7, "type_info": "Integer" }, { "name": "pinned!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "name", "ordinal": 9, "type_info": "Text" }, { "name": "worktree_deleted!: bool", "ordinal": 10, "type_info": "Bool" } ], "parameters": { "Right": 1 }, "nullable": [ true, true, true, false, true, false, false, false, false, true, false ] }, "hash": "f2dbb49b2f839e84a46fdd865d9982b758160517b93bc92d8e12060426daa05d" } ================================================ FILE: crates/db/.sqlx/query-f584dbe0f2f2a4f1e7dcf5b8f675eb2a6d954bb3f148ac0fece10652f05fb49b.json ================================================ { "db_name": "SQLite", "query": "SELECT ep.executor_action as \"executor_action!: sqlx::types::Json\"\n FROM sessions s\n JOIN execution_processes ep ON ep.session_id = s.id\n WHERE s.workspace_id = $1\n ORDER BY s.created_at ASC, ep.created_at ASC", "describe": { "columns": [ { "name": "executor_action!: sqlx::types::Json", "ordinal": 0, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ false ] }, "hash": "f584dbe0f2f2a4f1e7dcf5b8f675eb2a6d954bb3f148ac0fece10652f05fb49b" } ================================================ FILE: crates/db/.sqlx/query-f9e8640c28fae8aebf3d8b0d3984804fdb3f197c8cc2d5750fd267c82e3e68a1.json ================================================ { "db_name": "SQLite", "query": "SELECT DISTINCT r.id as \"id!: Uuid\",\n r.path,\n r.name,\n r.display_name,\n r.setup_script,\n r.cleanup_script,\n r.archive_script,\n r.copy_files,\n r.parallel_setup_script as \"parallel_setup_script!: bool\",\n r.dev_server_script,\n r.default_target_branch,\n r.default_working_dir,\n r.created_at as \"created_at!: DateTime\",\n r.updated_at as \"updated_at!: DateTime\"\n FROM repos r\n JOIN workspace_repos wr ON r.id = wr.repo_id\n JOIN workspaces w ON wr.workspace_id = w.id\n WHERE w.task_id = $1\n ORDER BY r.display_name ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "path", "ordinal": 1, "type_info": "Text" }, { "name": "name", "ordinal": 2, "type_info": "Text" }, { "name": "display_name", "ordinal": 3, "type_info": "Text" }, { "name": "setup_script", "ordinal": 4, "type_info": "Text" }, { "name": "cleanup_script", "ordinal": 5, "type_info": "Text" }, { "name": "archive_script", "ordinal": 6, "type_info": "Text" }, { "name": "copy_files", "ordinal": 7, "type_info": "Text" }, { "name": "parallel_setup_script!: bool", "ordinal": 8, "type_info": "Integer" }, { "name": "dev_server_script", "ordinal": 9, "type_info": "Text" }, { "name": "default_target_branch", "ordinal": 10, "type_info": "Text" }, { "name": "default_working_dir", "ordinal": 11, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 12, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 13, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, false, true, true, true, true, false, true, true, true, false, false ] }, "hash": "f9e8640c28fae8aebf3d8b0d3984804fdb3f197c8cc2d5750fd267c82e3e68a1" } ================================================ FILE: crates/db/.sqlx/query-faae305f6ac9dc7d04d21c76531cde3912647430195267ffa5b99bb9a7df1feb.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n workspace_id as \"workspace_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n merge_type as \"merge_type!: MergeType\",\n merge_commit,\n pr_number,\n pr_url,\n pr_status as \"pr_status?: MergeStatus\",\n pr_merged_at as \"pr_merged_at?: DateTime\",\n pr_merge_commit_sha,\n created_at as \"created_at!: DateTime\",\n target_branch_name as \"target_branch_name!: String\"\n FROM merges\n WHERE merge_type = 'pr' AND pr_status = 'open'\n ORDER BY created_at DESC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "workspace_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "merge_type!: MergeType", "ordinal": 3, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 4, "type_info": "Text" }, { "name": "pr_number", "ordinal": 5, "type_info": "Integer" }, { "name": "pr_url", "ordinal": 6, "type_info": "Text" }, { "name": "pr_status?: MergeStatus", "ordinal": 7, "type_info": "Text" }, { "name": "pr_merged_at?: DateTime", "ordinal": 8, "type_info": "Text" }, { "name": "pr_merge_commit_sha", "ordinal": 9, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 10, "type_info": "Text" }, { "name": "target_branch_name!: String", "ordinal": 11, "type_info": "Text" } ], "parameters": { "Right": 0 }, "nullable": [ true, false, false, false, true, true, true, true, true, true, false, false ] }, "hash": "faae305f6ac9dc7d04d21c76531cde3912647430195267ffa5b99bb9a7df1feb" } ================================================ FILE: crates/db/.sqlx/query-fb1ab168509b38eccf3064e2a90690a3fdef67a98fee7e5943689e61818d34f0.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n execution_process_id as \"execution_process_id!: Uuid\",\n repo_id as \"repo_id!: Uuid\",\n before_head_commit,\n after_head_commit,\n merge_commit,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM execution_process_repo_states\n WHERE execution_process_id = $1\n ORDER BY created_at ASC", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "execution_process_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "repo_id!: Uuid", "ordinal": 2, "type_info": "Blob" }, { "name": "before_head_commit", "ordinal": 3, "type_info": "Text" }, { "name": "after_head_commit", "ordinal": 4, "type_info": "Text" }, { "name": "merge_commit", "ordinal": 5, "type_info": "Text" }, { "name": "created_at!: DateTime", "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 7, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, false, true, true, true, false, false ] }, "hash": "fb1ab168509b38eccf3064e2a90690a3fdef67a98fee7e5943689e61818d34f0" } ================================================ FILE: crates/db/.sqlx/query-fc90f4dd7a408d6129aff95538de22c3a1ca018bc7837e3dc1c5aa0007844887.json ================================================ { "db_name": "SQLite", "query": "SELECT\n id as \"id!: Uuid\",\n execution_process_id as \"execution_process_id!: Uuid\",\n agent_session_id,\n agent_message_id,\n prompt,\n summary,\n seen as \"seen!: bool\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM coding_agent_turns\n WHERE execution_process_id = $1", "describe": { "columns": [ { "name": "id!: Uuid", "ordinal": 0, "type_info": "Blob" }, { "name": "execution_process_id!: Uuid", "ordinal": 1, "type_info": "Blob" }, { "name": "agent_session_id", "ordinal": 2, "type_info": "Text" }, { "name": "agent_message_id", "ordinal": 3, "type_info": "Text" }, { "name": "prompt", "ordinal": 4, "type_info": "Text" }, { "name": "summary", "ordinal": 5, "type_info": "Text" }, { "name": "seen!: bool", "ordinal": 6, "type_info": "Integer" }, { "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", "ordinal": 8, "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ true, false, true, true, true, true, false, false, false ] }, "hash": "fc90f4dd7a408d6129aff95538de22c3a1ca018bc7837e3dc1c5aa0007844887" } ================================================ FILE: crates/db/Cargo.toml ================================================ [package] name = "db" version = "0.1.33" edition = "2024" [dependencies] utils = { path = "../utils" } executors = { path = "../executors" } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } serde_with = { workspace = true } strum = "0.27.2" strum_macros = "0.27.2" futures = "0.3.32" ================================================ FILE: crates/db/migrations/20250617183714_init.sql ================================================ PRAGMA foreign_keys = ON; CREATE TABLE projects ( id BLOB PRIMARY KEY, name TEXT NOT NULL, git_repo_path TEXT NOT NULL DEFAULT '' UNIQUE, setup_script TEXT DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); CREATE TABLE tasks ( id BLOB PRIMARY KEY, project_id BLOB NOT NULL, title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','inprogress','done','cancelled','inreview')), created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE TABLE task_attempts ( id BLOB PRIMARY KEY, task_id BLOB NOT NULL, worktree_path TEXT NOT NULL, merge_commit TEXT, executor TEXT, stdout TEXT, stderr TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); CREATE TABLE task_attempt_activities ( id BLOB PRIMARY KEY, task_attempt_id BLOB NOT NULL, status TEXT NOT NULL DEFAULT 'init' CHECK (status IN ('init','setuprunning','setupcomplete','setupfailed','executorrunning','executorcomplete','executorfailed','paused')), note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE ); ================================================ FILE: crates/db/migrations/20250620212427_execution_processes.sql ================================================ PRAGMA foreign_keys = ON; CREATE TABLE execution_processes ( id BLOB PRIMARY KEY, task_attempt_id BLOB NOT NULL, process_type TEXT NOT NULL DEFAULT 'setupscript' CHECK (process_type IN ('setupscript','codingagent','devserver')), status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed','killed')), command TEXT NOT NULL, args TEXT, -- JSON array of arguments working_directory TEXT NOT NULL, stdout TEXT, stderr TEXT, exit_code INTEGER, started_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE ); CREATE INDEX idx_execution_processes_task_attempt_id ON execution_processes(task_attempt_id); CREATE INDEX idx_execution_processes_status ON execution_processes(status); CREATE INDEX idx_execution_processes_type ON execution_processes(process_type); ================================================ FILE: crates/db/migrations/20250620214100_remove_stdout_stderr_from_task_attempts.sql ================================================ PRAGMA foreign_keys = ON; -- Remove stdout and stderr columns from task_attempts table -- These are now tracked in the execution_processes table for better granularity -- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table -- First, create a new table without stdout and stderr CREATE TABLE task_attempts_new ( id BLOB PRIMARY KEY, task_id BLOB NOT NULL, worktree_path TEXT NOT NULL, merge_commit TEXT, executor TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); -- Copy data from old table to new table (excluding stdout and stderr) INSERT INTO task_attempts_new (id, task_id, worktree_path, merge_commit, executor, created_at, updated_at) SELECT id, task_id, worktree_path, merge_commit, executor, created_at, updated_at FROM task_attempts; -- Drop the old table DROP TABLE task_attempts; -- Rename the new table to the original name ALTER TABLE task_attempts_new RENAME TO task_attempts; ================================================ FILE: crates/db/migrations/20250621120000_relate_activities_to_execution_processes.sql ================================================ -- Migration to relate task_attempt_activities to execution_processes instead of task_attempts -- This migration will: -- 1. Drop and recreate the task_attempt_activities table with execution_process_id -- 2. Clear existing data as it cannot be migrated meaningfully -- Drop the existing table (this will wipe existing activity data) DROP TABLE IF EXISTS task_attempt_activities; -- Create the new table structure with execution_process_id foreign key CREATE TABLE task_attempt_activities ( id TEXT PRIMARY KEY, execution_process_id TEXT NOT NULL REFERENCES execution_processes(id) ON DELETE CASCADE, status TEXT NOT NULL, note TEXT, created_at DATETIME NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE ); -- Create index for efficient lookups by execution_process_id CREATE INDEX idx_task_attempt_activities_execution_process_id ON task_attempt_activities(execution_process_id); -- Create index for efficient lookups by created_at for ordering CREATE INDEX idx_task_attempt_activities_created_at ON task_attempt_activities(created_at); ================================================ FILE: crates/db/migrations/20250623120000_executor_sessions.sql ================================================ PRAGMA foreign_keys = ON; CREATE TABLE executor_sessions ( id BLOB PRIMARY KEY, task_attempt_id BLOB NOT NULL, execution_process_id BLOB NOT NULL, session_id TEXT, -- External session ID from Claude/Amp prompt TEXT, -- The prompt sent to the executor created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE, FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE ); CREATE INDEX idx_executor_sessions_task_attempt_id ON executor_sessions(task_attempt_id); CREATE INDEX idx_executor_sessions_execution_process_id ON executor_sessions(execution_process_id); CREATE INDEX idx_executor_sessions_session_id ON executor_sessions(session_id); ================================================ FILE: crates/db/migrations/20250623130000_add_executor_type_to_execution_processes.sql ================================================ PRAGMA foreign_keys = ON; -- Add executor_type column to execution_processes table ALTER TABLE execution_processes ADD COLUMN executor_type TEXT; ================================================ FILE: crates/db/migrations/20250625000000_add_dev_script_to_projects.sql ================================================ PRAGMA foreign_keys = ON; -- Add dev_script column to projects table ALTER TABLE projects ADD COLUMN dev_script TEXT DEFAULT ''; ================================================ FILE: crates/db/migrations/20250701000000_add_branch_to_task_attempts.sql ================================================ -- Add branch column to task_attempts table ALTER TABLE task_attempts ADD COLUMN branch TEXT NOT NULL DEFAULT ''; ================================================ FILE: crates/db/migrations/20250701000001_add_pr_tracking_to_task_attempts.sql ================================================ -- Add PR tracking fields to task_attempts table ALTER TABLE task_attempts ADD COLUMN pr_url TEXT; ALTER TABLE task_attempts ADD COLUMN pr_number INTEGER; ALTER TABLE task_attempts ADD COLUMN pr_status TEXT; -- open, closed, merged ALTER TABLE task_attempts ADD COLUMN pr_merged_at DATETIME; ================================================ FILE: crates/db/migrations/20250701120000_add_assistant_message_to_executor_sessions.sql ================================================ -- Add summary column to executor_sessions table ALTER TABLE executor_sessions ADD COLUMN summary TEXT; ================================================ FILE: crates/db/migrations/20250708000000_add_base_branch_to_task_attempts.sql ================================================ -- Add base_branch column to task_attempts table with default value ALTER TABLE task_attempts ADD COLUMN base_branch TEXT NOT NULL DEFAULT 'main'; ================================================ FILE: crates/db/migrations/20250709000000_add_worktree_deleted_flag.sql ================================================ -- Add worktree_deleted flag to track when worktrees are cleaned up ALTER TABLE task_attempts ADD COLUMN worktree_deleted BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: crates/db/migrations/20250710000000_add_setup_completion.sql ================================================ -- Add setup completion tracking to task_attempts table -- This enables automatic setup script execution for recreated worktrees ALTER TABLE task_attempts ADD COLUMN setup_completed_at DATETIME; ================================================ FILE: crates/db/migrations/20250715154859_add_task_templates.sql ================================================ -- Add task templates tables CREATE TABLE task_templates ( id BLOB PRIMARY KEY, project_id BLOB, -- NULL for global templates title TEXT NOT NULL, description TEXT, template_name TEXT NOT NULL, -- Display name for the template created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); -- Add index for faster queries CREATE INDEX idx_task_templates_project_id ON task_templates(project_id); -- Add unique constraints to prevent duplicate template names within same scope -- For project-specific templates: unique within each project CREATE UNIQUE INDEX idx_task_templates_unique_name_project ON task_templates(project_id, template_name) WHERE project_id IS NOT NULL; -- For global templates: unique across all global templates CREATE UNIQUE INDEX idx_task_templates_unique_name_global ON task_templates(template_name) WHERE project_id IS NULL; ================================================ FILE: crates/db/migrations/20250716143725_add_default_templates.sql ================================================ -- Add default global templates -- 1. Bug Analysis template INSERT INTO task_templates ( id, project_id, title, description, template_name, created_at, updated_at ) VALUES ( randomblob(16), NULL, -- Global template 'Analyze codebase for potential bugs and issues', 'Perform a comprehensive analysis of the project codebase to identify potential bugs, code smells, and areas of improvement. ## Analysis Checklist: ### 1. Static Code Analysis - [ ] Run linting tools to identify syntax and style issues - [ ] Check for unused variables, imports, and dead code - [ ] Identify potential type errors or mismatches - [ ] Look for deprecated API usage ### 2. Common Bug Patterns - [ ] Check for null/undefined reference errors - [ ] Identify potential race conditions - [ ] Look for improper error handling - [ ] Check for resource leaks (memory, file handles, connections) - [ ] Identify potential security vulnerabilities (XSS, SQL injection, etc.) ### 3. Code Quality Issues - [ ] Identify overly complex functions (high cyclomatic complexity) - [ ] Look for code duplication - [ ] Check for missing or inadequate input validation - [ ] Identify hardcoded values that should be configurable ### 4. Testing Gaps - [ ] Identify untested code paths - [ ] Check for missing edge case tests - [ ] Look for inadequate error scenario testing ### 5. Performance Concerns - [ ] Identify potential performance bottlenecks - [ ] Check for inefficient algorithms or data structures - [ ] Look for unnecessary database queries or API calls ## Deliverables: 1. Prioritized list of identified issues 2. Recommendations for fixes 3. Estimated effort for addressing each issue', 'Bug Analysis', datetime('now', 'subsec'), datetime('now', 'subsec') ); -- 2. Unit Test template INSERT INTO task_templates ( id, project_id, title, description, template_name, created_at, updated_at ) VALUES ( randomblob(16), NULL, -- Global template 'Add unit tests for [component/function]', 'Write unit tests to improve code coverage and ensure reliability. ## Unit Testing Checklist ### 1. Identify What to Test - [ ] Run coverage report to find untested functions - [ ] List the specific functions/methods to test - [ ] Note current coverage percentage ### 2. Write Tests - [ ] Test the happy path (expected behavior) - [ ] Test edge cases (empty inputs, boundaries) - [ ] Test error cases (invalid inputs, exceptions) - [ ] Mock external dependencies - [ ] Use descriptive test names ### 3. Test Quality - [ ] Each test focuses on one behavior - [ ] Tests can run independently - [ ] No hardcoded values that might change - [ ] Clear assertions that verify the behavior ## Examples to Cover: - Normal inputs → Expected outputs - Empty/null inputs → Proper handling - Invalid inputs → Error cases - Boundary values → Edge case behavior ## Goal Achieve at least 80% coverage for the target component ## Deliverables 1. New test file(s) with comprehensive unit tests 2. Updated coverage report 3. All tests passing', 'Add Unit Tests', datetime('now', 'subsec'), datetime('now', 'subsec') ); -- 3. Code Refactoring template INSERT INTO task_templates ( id, project_id, title, description, template_name, created_at, updated_at ) VALUES ( randomblob(16), NULL, -- Global template 'Refactor [component/module] for better maintainability', 'Improve code structure and maintainability without changing functionality. ## Refactoring Checklist ### 1. Identify Refactoring Targets - [ ] Run code analysis tools (linters, complexity analyzers) - [ ] Identify code smells (long methods, duplicate code, large classes) - [ ] Check for outdated patterns or deprecated approaches - [ ] Review areas with frequent bugs or changes ### 2. Plan the Refactoring - [ ] Define clear goals (what to improve and why) - [ ] Ensure tests exist for current functionality - [ ] Create a backup branch - [ ] Break down into small, safe steps ### 3. Common Refactoring Actions - [ ] Extract methods from long functions - [ ] Remove duplicate code (DRY principle) - [ ] Rename variables/functions for clarity - [ ] Simplify complex conditionals - [ ] Extract constants from magic numbers/strings - [ ] Group related functionality into modules - [ ] Remove dead code ### 4. Maintain Functionality - [ ] Run tests after each change - [ ] Keep changes small and incremental - [ ] Commit frequently with clear messages - [ ] Verify no behavior has changed ### 5. Code Quality Improvements - [ ] Apply consistent formatting - [ ] Update to modern syntax/features - [ ] Improve error handling - [ ] Add type annotations (if applicable) ## Success Criteria - All tests still pass - Code is more readable and maintainable - No new bugs introduced - Performance not degraded ## Deliverables 1. Refactored code with improved structure 2. All tests passing 3. Brief summary of changes made', 'Code Refactoring', datetime('now', 'subsec'), datetime('now', 'subsec') ); ================================================ FILE: crates/db/migrations/20250716161432_update_executor_names_to_kebab_case.sql ================================================ -- Migration to update executor type names from snake_case/camelCase to kebab-case -- This handles the change from charmopencode -> charm-opencode and setup_script -> setup-script -- Update task_attempts.executor column UPDATE task_attempts SET executor = 'charm-opencode' WHERE executor = 'charmopencode'; UPDATE task_attempts SET executor = 'setup-script' WHERE executor = 'setup_script'; -- Update execution_processes.executor_type column UPDATE execution_processes SET executor_type = 'charm-opencode' WHERE executor_type = 'charmopencode'; UPDATE execution_processes SET executor_type = 'setup-script' WHERE executor_type = 'setup_script'; ================================================ FILE: crates/db/migrations/20250716170000_add_parent_task_to_tasks.sql ================================================ PRAGMA foreign_keys = ON; -- Add parent_task_attempt column to tasks table ALTER TABLE tasks ADD COLUMN parent_task_attempt BLOB REFERENCES task_attempts(id); -- Create index for parent_task_attempt lookups CREATE INDEX idx_tasks_parent_task_attempt ON tasks(parent_task_attempt); ================================================ FILE: crates/db/migrations/20250717000000_drop_task_attempt_activities.sql ================================================ -- Migration to drop task_attempt_activities table -- This removes the task attempt activity tracking functionality -- Drop indexes first DROP INDEX IF EXISTS idx_task_attempt_activities_execution_process_id; DROP INDEX IF EXISTS idx_task_attempt_activities_created_at; -- Drop the table DROP TABLE IF EXISTS task_attempt_activities; ================================================ FILE: crates/db/migrations/20250719000000_add_cleanup_script_to_projects.sql ================================================ -- Add cleanup_script column to projects table ALTER TABLE projects ADD COLUMN cleanup_script TEXT; ================================================ FILE: crates/db/migrations/20250720000000_add_cleanupscript_to_process_type_constraint.sql ================================================ -- 1. Add the replacement column with the wider CHECK ALTER TABLE execution_processes ADD COLUMN process_type_new TEXT NOT NULL DEFAULT 'setupscript' CHECK (process_type_new IN ('setupscript', 'cleanupscript', -- new value 🎉 'codingagent', 'devserver')); -- 2. Copy existing values across UPDATE execution_processes SET process_type_new = process_type; -- 3. Drop any indexes that mention the old column DROP INDEX IF EXISTS idx_execution_processes_type; -- 4. Remove the old column (requires 3.35+) ALTER TABLE execution_processes DROP COLUMN process_type; -- 5. Rename the new column back to the canonical name ALTER TABLE execution_processes RENAME COLUMN process_type_new TO process_type; -- 6. Re-create the index CREATE INDEX idx_execution_processes_type ON execution_processes(process_type); ================================================ FILE: crates/db/migrations/20250726182144_update_worktree_path_to_container_ref.sql ================================================ -- Add migration script here ALTER TABLE task_attempts ADD COLUMN container_ref TEXT; -- nullable UPDATE task_attempts SET container_ref = worktree_path; -- If you might have triggers or indexes on worktree_path, drop them before this step. ALTER TABLE task_attempts DROP COLUMN worktree_path; ================================================ FILE: crates/db/migrations/20250726210910_make_branch_optional.sql ================================================ -- Add migration script here -- 1) Create replacement column (nullable TEXT) ALTER TABLE task_attempts ADD COLUMN branch_new TEXT; -- nullable -- 2) Copy existing values UPDATE task_attempts SET branch_new = branch; -- If you have indexes/triggers/constraints that reference "branch", -- drop them before the next two steps and recreate them afterwards. -- 3) Remove the old non-nullable column ALTER TABLE task_attempts DROP COLUMN branch; -- 4) Keep the original column name ALTER TABLE task_attempts RENAME COLUMN branch_new TO branch; ================================================ FILE: crates/db/migrations/20250727124142_remove_command_from_execution_process.sql ================================================ -- Add migration script here ALTER TABLE execution_processes DROP COLUMN command; ALTER TABLE execution_processes DROP COLUMN args; ================================================ FILE: crates/db/migrations/20250727150349_remove_working_directory.sql ================================================ -- Add migration script here ALTER TABLE execution_processes DROP COLUMN working_directory; ================================================ FILE: crates/db/migrations/20250729162941_create_execution_process_logs.sql ================================================ PRAGMA foreign_keys = ON; CREATE TABLE execution_process_logs ( execution_id BLOB PRIMARY KEY, logs TEXT NOT NULL, -- JSONL format (one LogMsg per line) byte_size INTEGER NOT NULL, inserted_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (execution_id) REFERENCES execution_processes(id) ON DELETE CASCADE ); CREATE INDEX idx_execution_process_logs_inserted_at ON execution_process_logs(inserted_at); ================================================ FILE: crates/db/migrations/20250729165913_remove_stdout_and_stderr_from_execution_processes.sql ================================================ -- Add migration script here ALTER TABLE execution_processes DROP COLUMN stdout; ALTER TABLE execution_processes DROP COLUMN stderr; ================================================ FILE: crates/db/migrations/20250730000000_add_executor_action_to_execution_processes.sql ================================================ PRAGMA foreign_keys = ON; -- Clear existing execution_processes records since we can't meaningfully migrate them -- (old records lack the actual script content and prompts needed for ExecutorActions) DELETE FROM execution_processes; -- Add executor_action column to execution_processes table for storing full ExecutorActions JSON ALTER TABLE execution_processes ADD COLUMN executor_action TEXT NOT NULL DEFAULT ''; ================================================ FILE: crates/db/migrations/20250730000001_rename_process_type_to_run_reason.sql ================================================ PRAGMA foreign_keys = ON; -- Rename process_type column to run_reason for better semantic clarity ALTER TABLE execution_processes RENAME COLUMN process_type TO run_reason; ================================================ FILE: crates/db/migrations/20250730124500_add_execution_process_task_attempt_index.sql ================================================ ALTER TABLE execution_processes ADD COLUMN executor_action_type TEXT GENERATED ALWAYS AS (json_extract(executor_action, '$.type')) VIRTUAL; CREATE INDEX idx_execution_processes_task_attempt_type_created ON execution_processes (task_attempt_id, executor_action_type, created_at DESC); ================================================ FILE: crates/db/migrations/20250805112332_add_executor_action_type_to_task_attempts.sql ================================================ -- Remove unused executor_type column from execution_processes ALTER TABLE execution_processes DROP COLUMN executor_type; ALTER TABLE task_attempts RENAME COLUMN executor TO base_coding_agent; ================================================ FILE: crates/db/migrations/20250805122100_fix_executor_action_type_virtual_column.sql ================================================ -- Drop the existing virtual column and index DROP INDEX IF EXISTS idx_execution_processes_task_attempt_type_created; ALTER TABLE execution_processes DROP COLUMN executor_action_type; -- Recreate the virtual column with the correct JSON path ALTER TABLE execution_processes ADD COLUMN executor_action_type TEXT GENERATED ALWAYS AS (json_extract(executor_action, '$.typ.type')) VIRTUAL; -- Recreate the index CREATE INDEX idx_execution_processes_task_attempt_type_created ON execution_processes (task_attempt_id, executor_action_type, created_at DESC); ================================================ FILE: crates/db/migrations/20250811000000_add_copy_files_to_projects.sql ================================================ -- Add copy_files column to projects table -- This field stores comma-separated file paths to copy from the original project directory to the worktree ALTER TABLE projects ADD COLUMN copy_files TEXT; ================================================ FILE: crates/db/migrations/20250813000001_rename_base_coding_agent_to_profile.sql ================================================ PRAGMA foreign_keys = ON; -- Rename base_coding_agent column to profile_label for better semantic clarity ALTER TABLE task_attempts RENAME COLUMN base_coding_agent TO profile; -- best effort attempt to not break older task attempts by mapping to profiles UPDATE task_attempts SET profile = CASE profile WHEN 'CLAUDE_CODE' THEN 'claude-code' WHEN 'CODEX' THEN 'codex' WHEN 'GEMINI' THEN 'gemini' WHEN 'AMP' THEN 'amp' WHEN 'OPENCODE' THEN 'opencode' END WHERE profile IS NOT NULL AND profile IN ('CLAUDE_CODE', 'CODEX', 'GEMINI', 'AMP', 'OPENCODE'); ================================================ FILE: crates/db/migrations/20250815100344_migrate_old_executor_actions.sql ================================================ -- JSON format changed, means you can access logs from old execution_processes UPDATE execution_processes SET executor_action = json_set( json_remove(executor_action, '$.typ.profile'), '$.typ.profile_variant_label', json_object( 'profile', json_extract(executor_action, '$.typ.profile'), 'variant', json('null') ) ) WHERE json_type(executor_action, '$.typ') IS NOT NULL AND json_type(executor_action, '$.typ.profile') = 'text'; ================================================ FILE: crates/db/migrations/20250818150000_refactor_images_to_junction_tables.sql ================================================ PRAGMA foreign_keys = ON; -- Refactor images table to use junction tables for many-to-many relationships -- This allows images to be associated with multiple tasks and execution processes -- No data migration needed as there are no existing users of the image system CREATE TABLE images ( id BLOB PRIMARY KEY, file_path TEXT NOT NULL, -- relative path within cache/images/ original_name TEXT NOT NULL, mime_type TEXT, size_bytes INTEGER, hash TEXT NOT NULL UNIQUE, -- SHA256 for deduplication created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); -- Create junction table for task-image associations CREATE TABLE task_images ( id BLOB PRIMARY KEY, task_id BLOB NOT NULL, image_id BLOB NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, UNIQUE(task_id, image_id) -- Prevent duplicate associations ); -- Create indexes for efficient querying CREATE INDEX idx_images_hash ON images(hash); CREATE INDEX idx_task_images_task_id ON task_images(task_id); CREATE INDEX idx_task_images_image_id ON task_images(image_id); ================================================ FILE: crates/db/migrations/20250819000000_move_merge_commit_to_merges_table.sql ================================================ -- Create enhanced merges table with type-specific columns CREATE TABLE merges ( id BLOB PRIMARY KEY, task_attempt_id BLOB NOT NULL, merge_type TEXT NOT NULL CHECK (merge_type IN ('direct', 'pr')), -- Direct merge fields (NULL for PR merges) merge_commit TEXT, -- PR merge fields (NULL for direct merges) pr_number INTEGER, pr_url TEXT, pr_status TEXT CHECK (pr_status IN ('open', 'merged', 'closed')), pr_merged_at TEXT, pr_merge_commit_sha TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), target_branch_name TEXT NOT NULL, -- Data integrity constraints CHECK ( (merge_type = 'direct' AND merge_commit IS NOT NULL AND pr_number IS NULL AND pr_url IS NULL) OR (merge_type = 'pr' AND pr_number IS NOT NULL AND pr_url IS NOT NULL AND pr_status IS NOT NULL AND merge_commit IS NULL) ), FOREIGN KEY (task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE ); -- Create general index for all task_attempt_id queries CREATE INDEX idx_merges_task_attempt_id ON merges(task_attempt_id); -- Create index for finding open PRs quickly CREATE INDEX idx_merges_open_pr ON merges(task_attempt_id, pr_status) WHERE merge_type = 'pr' AND pr_status = 'open'; -- Migrate existing merge_commit data to new table as direct merges INSERT INTO merges (id, task_attempt_id, merge_type, merge_commit, created_at, target_branch_name) SELECT randomblob(16), id, 'direct', merge_commit, updated_at, base_branch FROM task_attempts WHERE merge_commit IS NOT NULL; -- Migrate existing PR data from task_attempts to merges INSERT INTO merges (id, task_attempt_id, merge_type, pr_number, pr_url, pr_status, pr_merged_at, pr_merge_commit_sha, created_at, target_branch_name) SELECT randomblob(16), id, 'pr', pr_number, pr_url, CASE WHEN pr_status = 'merged' THEN 'merged' WHEN pr_status = 'closed' THEN 'closed' ELSE 'open' END, pr_merged_at, NULL, -- We don't have merge_commit for PRs in task_attempts COALESCE(pr_merged_at, updated_at), base_branch FROM task_attempts WHERE pr_number IS NOT NULL; -- Drop merge_commit column from task_attempts ALTER TABLE task_attempts DROP COLUMN merge_commit; -- Drop PR columns from task_attempts ALTER TABLE task_attempts DROP COLUMN pr_url; ALTER TABLE task_attempts DROP COLUMN pr_number; ALTER TABLE task_attempts DROP COLUMN pr_status; ALTER TABLE task_attempts DROP COLUMN pr_merged_at; ================================================ FILE: crates/db/migrations/20250902120000_add_masked_by_restore_to_execution_processes.sql ================================================ -- Add a boolean flag to mark processes as dropped (excluded from timeline/logs) ALTER TABLE execution_processes ADD COLUMN dropped BOOLEAN NOT NULL DEFAULT 0; ================================================ FILE: crates/db/migrations/20250902184501_rename-profile-to-executor.sql ================================================ -- Add migration script here ALTER TABLE task_attempts RENAME COLUMN profile TO executor; ================================================ FILE: crates/db/migrations/20250903091032_executors_to_screaming_snake.sql ================================================ -- Converts pascal/camel to SCREAMING_SNAKE UPDATE task_attempts SET executor = ( WITH RECURSIVE x(s, i, out) AS ( SELECT executor, 1, '' UNION ALL SELECT s, i+1, out || CASE WHEN i = 1 THEN substr(s,1,1) WHEN (substr(s,i,1) BETWEEN 'A' AND 'Z') AND ( (substr(s,i-1,1) BETWEEN 'a' AND 'z') OR (substr(s,i-1,1) BETWEEN '0' AND '9') OR ((substr(s,i-1,1) BETWEEN 'A' AND 'Z') AND i < length(s) AND substr(s,i+1,1) BETWEEN 'a' AND 'z') ) THEN '_' || substr(s,i,1) ELSE substr(s,i,1) END FROM x WHERE i <= length(s) ) SELECT UPPER(out) FROM x WHERE i = length(s) + 1 ); ================================================ FILE: crates/db/migrations/20250905090000_add_after_head_commit_to_execution_processes.sql ================================================ -- Add after_head_commit column to store commit OID after a process ends ALTER TABLE execution_processes ADD COLUMN after_head_commit TEXT; ================================================ FILE: crates/db/migrations/20250906120000_add_follow_up_drafts.sql ================================================ -- Follow-up drafts per task attempt -- Stores a single draft prompt that can be queued for the next available run CREATE TABLE IF NOT EXISTS follow_up_drafts ( id TEXT PRIMARY KEY, task_attempt_id TEXT NOT NULL UNIQUE, prompt TEXT NOT NULL DEFAULT '', queued INTEGER NOT NULL DEFAULT 0, sending INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 0, variant TEXT NULL, image_ids TEXT NULL, -- JSON array of UUID strings created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_follow_up_drafts_task_attempt_id ON follow_up_drafts(task_attempt_id); -- Trigger to keep updated_at current CREATE TRIGGER IF NOT EXISTS trg_follow_up_drafts_updated_at AFTER UPDATE ON follow_up_drafts FOR EACH ROW BEGIN UPDATE follow_up_drafts SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; END; ================================================ FILE: crates/db/migrations/20250910120000_add_before_head_commit_to_execution_processes.sql ================================================ -- Add before_head_commit column to store commit OID before a process starts ALTER TABLE execution_processes ADD COLUMN before_head_commit TEXT; -- Backfill before_head_commit for legacy rows using the previous process's after_head_commit UPDATE execution_processes AS ep SET before_head_commit = ( SELECT prev.after_head_commit FROM execution_processes prev WHERE prev.task_attempt_id = ep.task_attempt_id AND prev.created_at = ( SELECT max(created_at) FROM execution_processes WHERE task_attempt_id = ep.task_attempt_id AND created_at < ep.created_at ) ) WHERE ep.before_head_commit IS NULL AND ep.after_head_commit IS NOT NULL; ================================================ FILE: crates/db/migrations/20250917123000_optimize_selects_and_cleanup_indexes.sql ================================================ PRAGMA foreign_keys = ON; -- 1) task_attempts: filter by task_id and sort by created_at DESC CREATE INDEX IF NOT EXISTS idx_task_attempts_task_id_created_at ON task_attempts (task_id, created_at DESC); -- Global listing ordered by created_at DESC CREATE INDEX IF NOT EXISTS idx_task_attempts_created_at ON task_attempts (created_at DESC); -- 2) execution_processes: filter by task_attempt_id and sort by created_at ASC CREATE INDEX IF NOT EXISTS idx_execution_processes_task_attempt_created_at ON execution_processes (task_attempt_id, created_at ASC); -- Drop redundant single-column index superseded by the composite above DROP INDEX IF EXISTS idx_execution_processes_task_attempt_id; -- 3) tasks: list by project ordered by created_at DESC CREATE INDEX IF NOT EXISTS idx_tasks_project_created_at ON tasks (project_id, created_at DESC); ================================================ FILE: crates/db/migrations/20250921222241_unify_drafts_tables.sql ================================================ -- Unify follow_up_drafts and retry_drafts into a single drafts table -- This migration consolidates the duplicate code between the two draft types -- Create the unified drafts table CREATE TABLE IF NOT EXISTS drafts ( id TEXT PRIMARY KEY, task_attempt_id TEXT NOT NULL, draft_type TEXT NOT NULL CHECK(draft_type IN ('follow_up', 'retry')), retry_process_id TEXT NULL, -- Only used for retry drafts prompt TEXT NOT NULL DEFAULT '', queued INTEGER NOT NULL DEFAULT 0, sending INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 0, variant TEXT NULL, image_ids TEXT NULL, -- JSON array of UUID strings created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(task_attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE, FOREIGN KEY(retry_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE, -- Unique constraint: only one draft per task_attempt_id and draft_type UNIQUE(task_attempt_id, draft_type) ); -- Create indexes CREATE INDEX IF NOT EXISTS idx_drafts_task_attempt_id ON drafts(task_attempt_id); CREATE INDEX IF NOT EXISTS idx_drafts_draft_type ON drafts(draft_type); CREATE INDEX IF NOT EXISTS idx_drafts_queued_sending ON drafts(queued, sending) WHERE queued = 1; -- Migrate existing follow_up_drafts INSERT INTO drafts ( id, task_attempt_id, draft_type, retry_process_id, prompt, queued, sending, version, variant, image_ids, created_at, updated_at ) SELECT id, task_attempt_id, 'follow_up', NULL, prompt, queued, sending, version, variant, image_ids, created_at, updated_at FROM follow_up_drafts; -- Drop old tables DROP TABLE IF EXISTS follow_up_drafts; -- Create trigger to keep updated_at current CREATE TRIGGER IF NOT EXISTS trg_drafts_updated_at AFTER UPDATE ON drafts FOR EACH ROW BEGIN UPDATE drafts SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; END; ================================================ FILE: crates/db/migrations/20250923000000_make_branch_non_null.sql ================================================ -- Make branch column NOT NULL by recreating it -- First update any NULL values to 'main' -- Note: NULL values should not exist in practice, this is just a safety measure UPDATE task_attempts SET branch = 'main' WHERE branch IS NULL; -- 1) Create replacement column (NOT NULL TEXT) ALTER TABLE task_attempts ADD COLUMN branch_new TEXT NOT NULL DEFAULT 'main'; -- 2) Copy existing values UPDATE task_attempts SET branch_new = branch; -- 3) Remove the old nullable column ALTER TABLE task_attempts DROP COLUMN branch; -- 4) Keep the original column name ALTER TABLE task_attempts RENAME COLUMN branch_new TO branch; -- Rename base_branch to target_branch now that we only need one column ALTER TABLE task_attempts RENAME COLUMN base_branch TO target_branch; ================================================ FILE: crates/db/migrations/20251020120000_convert_templates_to_tags.sql ================================================ -- Convert task_templates to tags -- Migrate ALL templates with snake_case conversion CREATE TABLE tags ( id BLOB PRIMARY KEY, tag_name TEXT NOT NULL CHECK(INSTR(tag_name, ' ') = 0), content TEXT NOT NULL CHECK(content != ''), created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); -- Only migrate templates that have non-empty descriptions -- Templates with empty/null descriptions are skipped INSERT INTO tags (id, tag_name, content, created_at, updated_at) SELECT id, LOWER(REPLACE(template_name, ' ', '_')) as tag_name, description, created_at, updated_at FROM task_templates WHERE description IS NOT NULL AND description != ''; DROP INDEX idx_task_templates_project_id; DROP INDEX idx_task_templates_unique_name_project; DROP INDEX idx_task_templates_unique_name_global; DROP TABLE task_templates; ================================================ FILE: crates/db/migrations/20251101090000_drop_execution_process_logs_pk.sql ================================================ -- Migration steps following the official SQLite "12-step generalized ALTER TABLE" procedure: -- https://www.sqlite.org/lang_altertable.html#otheralter -- PRAGMA foreign_keys = OFF; -- This is a sqlx workaround to enable BEGIN TRANSACTION in this migration, until `-- no-transaction` lands in sqlx-sqlite. -- https://github.com/launchbadge/sqlx/issues/2085#issuecomment-1499859906 COMMIT TRANSACTION; BEGIN TRANSACTION; -- Create replacement table without the PRIMARY KEY constraint on execution_id. CREATE TABLE execution_process_logs_new ( execution_id BLOB NOT NULL, logs TEXT NOT NULL, -- JSONL format (one LogMsg per line) byte_size INTEGER NOT NULL, inserted_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (execution_id) REFERENCES execution_processes(id) ON DELETE CASCADE ); -- Copy existing data into the replacement table. INSERT INTO execution_process_logs_new ( execution_id, logs, byte_size, inserted_at ) SELECT execution_id, logs, byte_size, inserted_at FROM execution_process_logs; -- Drop the original table. DROP TABLE execution_process_logs; -- Rename the new table into place. ALTER TABLE execution_process_logs_new RENAME TO execution_process_logs; -- Rebuild indexes to preserve performance characteristics. CREATE INDEX IF NOT EXISTS idx_execution_process_logs_execution_id_inserted_at ON execution_process_logs (execution_id, inserted_at); -- Verify foreign key constraints before committing the transaction. PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON; -- sqlx workaround due to lack of `-- no-transaction` in sqlx-sqlite. BEGIN TRANSACTION; ================================================ FILE: crates/db/migrations/20251114000000_create_shared_tasks.sql ================================================ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS shared_tasks ( id BLOB PRIMARY KEY, remote_project_id BLOB NOT NULL, title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','inprogress','done','cancelled','inreview')), assignee_user_id BLOB, assignee_first_name TEXT, assignee_last_name TEXT, assignee_username TEXT, version INTEGER NOT NULL DEFAULT 1, last_event_seq INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); CREATE INDEX IF NOT EXISTS idx_shared_tasks_remote_project ON shared_tasks (remote_project_id); CREATE INDEX IF NOT EXISTS idx_shared_tasks_status ON shared_tasks (status); CREATE TABLE IF NOT EXISTS shared_activity_cursors ( remote_project_id BLOB PRIMARY KEY, last_seq INTEGER NOT NULL CHECK (last_seq >= 0), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); ALTER TABLE tasks ADD COLUMN shared_task_id BLOB REFERENCES shared_tasks(id) ON DELETE SET NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_shared_task_unique ON tasks(shared_task_id) WHERE shared_task_id IS NOT NULL; ALTER TABLE projects ADD COLUMN remote_project_id BLOB; CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_remote_project_id ON projects(remote_project_id) WHERE remote_project_id IS NOT NULL; ================================================ FILE: crates/db/migrations/20251120000001_refactor_to_scratch.sql ================================================ CREATE TABLE scratch ( id BLOB NOT NULL, scratch_type TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), PRIMARY KEY (id, scratch_type) ); CREATE INDEX idx_scratch_created_at ON scratch(created_at); ================================================ FILE: crates/db/migrations/20251129155145_drop_drafts_table.sql ================================================ -- Drop the drafts table (follow-up and retry drafts are no longer used) DROP TABLE IF EXISTS drafts; ================================================ FILE: crates/db/migrations/20251202000000_migrate_to_electric.sql ================================================ DROP TABLE IF EXISTS shared_activity_cursors; -- Drop the index on the old column if it exists DROP INDEX IF EXISTS idx_tasks_shared_task_unique; -- Add new column to hold the data ALTER TABLE tasks ADD COLUMN shared_task_id_new BLOB; -- Migrate data UPDATE tasks SET shared_task_id_new = shared_task_id; -- Drop the old column (removing the foreign key constraint) ALTER TABLE tasks DROP COLUMN shared_task_id; -- Rename the new column to the old name ALTER TABLE tasks RENAME COLUMN shared_task_id_new TO shared_task_id; -- Recreate the index CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_shared_task_unique ON tasks(shared_task_id) WHERE shared_task_id IS NOT NULL; DROP TABLE IF EXISTS shared_tasks; ================================================ FILE: crates/db/migrations/20251206000000_add_parallel_setup_script_to_projects.sql ================================================ -- Add parallel_setup_script column to projects table -- When true, setup script runs in parallel with coding agent instead of sequentially ALTER TABLE projects ADD COLUMN parallel_setup_script INTEGER NOT NULL DEFAULT 0; ================================================ FILE: crates/db/migrations/20251209000000_add_project_repositories.sql ================================================ -- Step 1: Create global repos registry CREATE TABLE repos ( id BLOB PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL, display_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); -- Step 2: Create project_repos junction with per-repo script fields CREATE TABLE project_repos ( id BLOB PRIMARY KEY, project_id BLOB NOT NULL, repo_id BLOB NOT NULL, setup_script TEXT, cleanup_script TEXT, copy_files TEXT, parallel_setup_script INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, UNIQUE (project_id, repo_id) ); CREATE INDEX idx_project_repos_project_id ON project_repos(project_id); CREATE INDEX idx_project_repos_repo_id ON project_repos(repo_id); -- Step 3: Create attempt_repos CREATE TABLE attempt_repos ( id BLOB PRIMARY KEY, attempt_id BLOB NOT NULL, repo_id BLOB NOT NULL, target_branch TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (attempt_id) REFERENCES task_attempts(id) ON DELETE CASCADE, FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, UNIQUE (attempt_id, repo_id) ); CREATE INDEX idx_attempt_repos_attempt_id ON attempt_repos(attempt_id); CREATE INDEX idx_attempt_repos_repo_id ON attempt_repos(repo_id); -- Step 4: Execution process repo states CREATE TABLE execution_process_repo_states ( id BLOB PRIMARY KEY, execution_process_id BLOB NOT NULL, repo_id BLOB NOT NULL, before_head_commit TEXT, after_head_commit TEXT, merge_commit TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE, FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, UNIQUE (execution_process_id, repo_id) ); CREATE INDEX idx_eprs_process_id ON execution_process_repo_states(execution_process_id); CREATE INDEX idx_eprs_repo_id ON execution_process_repo_states(repo_id); -- Step 5: Add repo_id to merges table for multi-repo support ALTER TABLE merges ADD COLUMN repo_id BLOB REFERENCES repos(id); CREATE INDEX idx_merges_repo_id ON merges(repo_id); -- Step 6: Migrate existing projects to repos -- Name/display_name use a sentinel that gets fixed by Rust backfill at startup. -- This avoids fragile SQL string manipulation and handles Windows paths correctly. INSERT INTO repos (id, path, name, display_name) SELECT randomblob(16), git_repo_path, '__NEEDS_BACKFILL__', '__NEEDS_BACKFILL__' FROM projects WHERE git_repo_path IS NOT NULL AND git_repo_path != ''; INSERT INTO project_repos (id, project_id, repo_id, setup_script, cleanup_script, copy_files, parallel_setup_script) SELECT randomblob(16), p.id, r.id, p.setup_script, p.cleanup_script, p.copy_files, p.parallel_setup_script FROM projects p JOIN repos r ON r.path = p.git_repo_path WHERE p.git_repo_path IS NOT NULL AND p.git_repo_path != ''; -- Step 7: Migrate task_attempt.target_branch INSERT INTO attempt_repos (id, attempt_id, repo_id, target_branch, created_at, updated_at) SELECT randomblob(16), ta.id, r.id, ta.target_branch, ta.created_at, ta.updated_at FROM task_attempts ta JOIN tasks t ON t.id = ta.task_id JOIN project_repos pr ON pr.project_id = t.project_id JOIN repos r ON r.id = pr.repo_id; -- Step 8: Backfill merges.repo_id from attempt_repos UPDATE merges SET repo_id = ( SELECT ar.repo_id FROM attempt_repos ar WHERE ar.attempt_id = merges.task_attempt_id LIMIT 1 ); -- Step 9: Make merges.repo_id NOT NULL DROP INDEX idx_merges_repo_id; ALTER TABLE merges ADD COLUMN repo_id_new BLOB NOT NULL DEFAULT X'00'; UPDATE merges SET repo_id_new = repo_id; ALTER TABLE merges DROP COLUMN repo_id; ALTER TABLE merges RENAME COLUMN repo_id_new TO repo_id; CREATE INDEX idx_merges_repo_id ON merges(repo_id); -- Step 10: Backfill per-repo state INSERT INTO execution_process_repo_states ( id, execution_process_id, repo_id, before_head_commit, after_head_commit ) SELECT randomblob(16), ep.id, r.id, ep.before_head_commit, ep.after_head_commit FROM execution_processes ep JOIN task_attempts ta ON ta.id = ep.task_attempt_id JOIN tasks t ON t.id = ta.task_id JOIN project_repos pr ON pr.project_id = t.project_id JOIN repos r ON r.id = pr.repo_id; -- Step 11: Cleanup old columns (Modern SQLite Syntax) -- Note: Old worktrees are migrated on-demand via WorkspaceManager::migrate_legacy_worktree -- using `git worktree move` to preserve existing work ALTER TABLE execution_processes DROP COLUMN before_head_commit; ALTER TABLE execution_processes DROP COLUMN after_head_commit; ALTER TABLE task_attempts DROP COLUMN target_branch; -- Step 12: Recreate projects table to remove `git_repo_path` (which has a UNIQUE constraint) COMMIT; PRAGMA foreign_keys = OFF; -- This is a sqlx workaround to enable BEGIN TRANSACTION in this migration -- This commits Steps 1-8 immediately. BEGIN TRANSACTION; -- Create replacement table (keeps dev_script, moves other scripts to project_repos) CREATE TABLE projects_new ( id BLOB PRIMARY KEY, name TEXT NOT NULL, dev_script TEXT, remote_project_id BLOB, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) ); INSERT INTO projects_new (id, name, dev_script, remote_project_id, created_at, updated_at) SELECT id, name, dev_script, remote_project_id, created_at, updated_at FROM projects; -- Drop the original table DROP TABLE projects; -- Rename the new table into place ALTER TABLE projects_new RENAME TO projects; -- Rebuild indexes to preserve performance/constraints CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_remote_project_id ON projects(remote_project_id) WHERE remote_project_id IS NOT NULL; -- Verify foreign key constraints before committing the transaction PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON; -- sqlx workaround due to lack of `-- no-transaction` in sqlx-sqlite. -- Starts a new empty transaction for sqlx to close successfully. BEGIN TRANSACTION; ================================================ FILE: crates/db/migrations/20251215145026_drop_worktree_deleted.sql ================================================ ALTER TABLE task_attempts DROP COLUMN worktree_deleted; ================================================ FILE: crates/db/migrations/20251216000000_add_dev_script_working_dir_to_projects.sql ================================================ ALTER TABLE projects ADD COLUMN dev_script_working_dir TEXT DEFAULT ''; ================================================ FILE: crates/db/migrations/20251216142123_refactor_task_attempts_to_workspaces_sessions.sql ================================================ -- Refactor task_attempts into workspaces and sessions -- - Rename task_attempts -> workspaces (keeps workspace-related fields) -- - Create sessions table (executor moves here) -- - Update execution_processes.task_attempt_id -> session_id -- - Rename executor_sessions -> coding_agent_turns (drop redundant task_attempt_id) -- - Rename merges.task_attempt_id -> workspace_id -- - Rename tasks.parent_task_attempt -> parent_workspace_id -- 1. Rename task_attempts to workspaces (FK refs auto-update in schema) ALTER TABLE task_attempts RENAME TO workspaces; -- 2. Create sessions table CREATE TABLE sessions ( id BLOB PRIMARY KEY, workspace_id BLOB NOT NULL, executor TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); CREATE INDEX idx_sessions_workspace_id ON sessions(workspace_id); -- 3. Migrate data: create one session per workspace INSERT INTO sessions (id, workspace_id, executor, created_at, updated_at) SELECT randomblob(16), id, executor, created_at, updated_at FROM workspaces; -- 4. Drop executor column from workspaces ALTER TABLE workspaces DROP COLUMN executor; -- 5. Rename merges.task_attempt_id to workspace_id DROP INDEX idx_merges_task_attempt_id; DROP INDEX idx_merges_open_pr; ALTER TABLE merges RENAME COLUMN task_attempt_id TO workspace_id; CREATE INDEX idx_merges_workspace_id ON merges(workspace_id); CREATE INDEX idx_merges_open_pr ON merges(workspace_id, pr_status) WHERE merge_type = 'pr' AND pr_status = 'open'; -- 6. Rename tasks.parent_task_attempt to parent_workspace_id DROP INDEX IF EXISTS idx_tasks_parent_task_attempt; ALTER TABLE tasks RENAME COLUMN parent_task_attempt TO parent_workspace_id; CREATE INDEX idx_tasks_parent_workspace_id ON tasks(parent_workspace_id); -- Steps 7-8 need FK disabled to avoid cascade deletes during DROP TABLE -- sqlx workaround: end auto-transaction to allow PRAGMA to take effect -- https://github.com/launchbadge/sqlx/issues/2085#issuecomment-1499859906 COMMIT; PRAGMA foreign_keys = OFF; BEGIN TRANSACTION; -- 7. Update execution_processes to reference session_id instead of task_attempt_id -- (needs rebuild because FK target changes from workspaces to sessions) DROP INDEX IF EXISTS idx_execution_processes_task_attempt_created_at; DROP INDEX IF EXISTS idx_execution_processes_task_attempt_type_created; CREATE TABLE execution_processes_new ( id BLOB PRIMARY KEY, session_id BLOB NOT NULL, run_reason TEXT NOT NULL DEFAULT 'setupscript' CHECK (run_reason IN ('setupscript','codingagent','devserver','cleanupscript')), executor_action TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed','killed')), exit_code INTEGER, dropped INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); -- Join through sessions to get the correct session_id for each execution_process INSERT INTO execution_processes_new (id, session_id, run_reason, executor_action, status, exit_code, dropped, started_at, completed_at, created_at, updated_at) SELECT ep.id, s.id, ep.run_reason, ep.executor_action, ep.status, ep.exit_code, ep.dropped, ep.started_at, ep.completed_at, ep.created_at, ep.updated_at FROM execution_processes ep JOIN sessions s ON ep.task_attempt_id = s.workspace_id; DROP TABLE execution_processes; ALTER TABLE execution_processes_new RENAME TO execution_processes; -- Recreate execution_processes indexes CREATE INDEX idx_execution_processes_session_id ON execution_processes(session_id); CREATE INDEX idx_execution_processes_status ON execution_processes(status); CREATE INDEX idx_execution_processes_run_reason ON execution_processes(run_reason); -- Composite indexes for Task::find_by_project_id_with_attempt_status query optimization CREATE INDEX idx_execution_processes_session_status_run_reason ON execution_processes (session_id, status, run_reason); CREATE INDEX idx_execution_processes_session_run_reason_created ON execution_processes (session_id, run_reason, created_at DESC); -- 8. Rename executor_sessions to coding_agent_turns and drop task_attempt_id -- (needs rebuild to drop the redundant task_attempt_id column) -- Also rename session_id to agent_session_id for clarity CREATE TABLE coding_agent_turns ( id BLOB PRIMARY KEY, execution_process_id BLOB NOT NULL, agent_session_id TEXT, prompt TEXT, summary TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (execution_process_id) REFERENCES execution_processes(id) ON DELETE CASCADE ); INSERT INTO coding_agent_turns (id, execution_process_id, agent_session_id, prompt, summary, created_at, updated_at) SELECT id, execution_process_id, session_id, prompt, summary, created_at, updated_at FROM executor_sessions; DROP TABLE executor_sessions; -- Recreate coding_agent_turns indexes CREATE INDEX idx_coding_agent_turns_execution_process_id ON coding_agent_turns(execution_process_id); CREATE INDEX idx_coding_agent_turns_agent_session_id ON coding_agent_turns(agent_session_id); -- 9. Rename attempt_repos to workspace_repos and attempt_id to workspace_id ALTER TABLE attempt_repos RENAME TO workspace_repos; ALTER TABLE workspace_repos RENAME COLUMN attempt_id TO workspace_id; DROP INDEX idx_attempt_repos_attempt_id; DROP INDEX idx_attempt_repos_repo_id; CREATE INDEX idx_workspace_repos_workspace_id ON workspace_repos(workspace_id); CREATE INDEX idx_workspace_repos_repo_id ON workspace_repos(repo_id); -- Verify foreign key constraints before committing PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON; -- sqlx workaround: start empty transaction for sqlx to close gracefully BEGIN TRANSACTION; ================================================ FILE: crates/db/migrations/20251219000000_add_agent_working_dir_to_projects.sql ================================================ -- Add column with empty default first (named default_ because it's the default for new workspaces) ALTER TABLE projects ADD COLUMN default_agent_working_dir TEXT DEFAULT ''; -- Copy existing dev_script_working_dir values to default_agent_working_dir -- ONLY for single-repo projects (multi-repo projects should default to None/empty) UPDATE projects SET default_agent_working_dir = dev_script_working_dir WHERE dev_script_working_dir IS NOT NULL AND dev_script_working_dir != '' AND (SELECT COUNT(*) FROM project_repos WHERE project_repos.project_id = projects.id) = 1; -- Add agent_working_dir to workspaces (snapshot of project's default at workspace creation) ALTER TABLE workspaces ADD COLUMN agent_working_dir TEXT DEFAULT ''; ================================================ FILE: crates/db/migrations/20251219164205_add_missing_indexes_for_slow_queries.sql ================================================ CREATE INDEX IF NOT EXISTS idx_sessions_workspace_id_created_at ON sessions (workspace_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at DESC); CREATE INDEX IF NOT EXISTS idx_workspaces_container_ref ON workspaces (container_ref) WHERE container_ref IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_eprs_process_repo ON execution_process_repo_states (execution_process_id, repo_id); PRAGMA optimize; ================================================ FILE: crates/db/migrations/20251220134608_fix_session_executor_format.sql ================================================ -- Fix session executor values that were incorrectly stored with variant suffix -- Values like "CLAUDE_CODE:ROUTER" should be "CLAUDE_CODE" -- This was introduced in the refactor from task_attempts to sessions (commit 6a129d0fa) UPDATE sessions SET executor = substr(executor, 1, instr(executor, ':') - 1), updated_at = datetime('now', 'subsec') WHERE executor LIKE '%:%'; ================================================ FILE: crates/db/migrations/20251221000000_add_workspace_flags.sql ================================================ -- Add workspace flags for archived, pinned, and name ALTER TABLE workspaces ADD COLUMN archived INTEGER NOT NULL DEFAULT 0; ALTER TABLE workspaces ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; ALTER TABLE workspaces ADD COLUMN name TEXT; -- Archive workspaces for completed/cancelled tasks UPDATE workspaces SET archived = 1 WHERE task_id IN ( SELECT id FROM tasks WHERE status IN ('done', 'cancelled') ); ================================================ FILE: crates/db/migrations/20260107000000_move_scripts_to_repos.sql ================================================ -- Add script columns to repos ALTER TABLE repos ADD COLUMN setup_script TEXT; ALTER TABLE repos ADD COLUMN cleanup_script TEXT; ALTER TABLE repos ADD COLUMN copy_files TEXT; ALTER TABLE repos ADD COLUMN parallel_setup_script INTEGER NOT NULL DEFAULT 0; ALTER TABLE repos ADD COLUMN dev_server_script TEXT; -- Migrate from first project_repo (by rowid) for each repo UPDATE repos SET setup_script = (SELECT pr.setup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1), cleanup_script = (SELECT pr.cleanup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1), copy_files = (SELECT pr.copy_files FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1), parallel_setup_script = COALESCE((SELECT pr.parallel_setup_script FROM project_repos pr WHERE pr.repo_id = repos.id ORDER BY pr.rowid ASC LIMIT 1), 0); -- Migrate dev_script directly from projects to repos (via first project_repo) UPDATE repos SET dev_server_script = ( SELECT p.dev_script FROM projects p JOIN project_repos pr ON pr.project_id = p.id WHERE pr.repo_id = repos.id AND p.dev_script IS NOT NULL AND p.dev_script != '' ORDER BY pr.rowid ASC LIMIT 1 ); -- Remove script columns from project_repos ALTER TABLE project_repos DROP COLUMN setup_script; ALTER TABLE project_repos DROP COLUMN cleanup_script; ALTER TABLE project_repos DROP COLUMN copy_files; ALTER TABLE project_repos DROP COLUMN parallel_setup_script; -- Remove dev_script columns from projects ALTER TABLE projects DROP COLUMN dev_script; ALTER TABLE projects DROP COLUMN dev_script_working_dir; ================================================ FILE: crates/db/migrations/20260107115155_add_seen_to_coding_agent_turns.sql ================================================ -- Add 'seen' column to coding_agent_turns table -- New turns default to unseen (0), marked as seen (1) when user views the workspace ALTER TABLE coding_agent_turns ADD COLUMN seen INTEGER NOT NULL DEFAULT 0; ================================================ FILE: crates/db/migrations/20260112160045_add_composite_indexes_for_performance.sql ================================================ -- Add composite index for workspace_repos lookup queries -- This optimizes queries like: WHERE workspace_id = $1 AND repo_id = $2 -- which were taking up to 5 seconds without this index CREATE INDEX IF NOT EXISTS idx_workspace_repos_lookup ON workspace_repos (workspace_id, repo_id); -- Add composite index for merges status filtering -- This optimizes queries like: WHERE merge_type = 'pr' AND pr_status = 'open' -- which were taking 2+ seconds without proper indexing CREATE INDEX IF NOT EXISTS idx_merges_type_status ON merges (merge_type, pr_status); -- Optimize database after adding indexes PRAGMA optimize; ================================================ FILE: crates/db/migrations/20260113144821_remove_shared_tasks.sql ================================================ -- Remove shared task functionality -- Drop index first DROP INDEX IF EXISTS idx_tasks_shared_task_unique; -- Remove shared_task_id column from tasks table ALTER TABLE tasks DROP COLUMN shared_task_id; -- Drop the shared_tasks related tables DROP TABLE IF EXISTS shared_activity_cursors; DROP TABLE IF EXISTS shared_tasks; ================================================ FILE: crates/db/migrations/20260122000000_add_default_target_branch_to_repos.sql ================================================ -- Add default_target_branch column to repos table ALTER TABLE repos ADD COLUMN default_target_branch TEXT; ================================================ FILE: crates/db/migrations/20260123125956_add_agent_message_id.sql ================================================ -- Add agent_message_id column to coding_agent_turns -- This stores the last message ID from the agent for use with --resume-session-at ALTER TABLE coding_agent_turns ADD COLUMN agent_message_id TEXT; ================================================ FILE: crates/db/migrations/20260126000000_add_agent_working_dir_to_repos.sql ================================================ -- Add default_working_dir to repos for monorepo support -- Allows users to specify the subdirectory where the coding agent should run ALTER TABLE repos ADD COLUMN default_working_dir TEXT; ================================================ FILE: crates/db/migrations/20260128000000_add_migration_state.sql ================================================ -- Migration state tracking table for local→remote data migration CREATE TABLE IF NOT EXISTS migration_state ( id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, -- 'project', 'task', 'pr_merge' local_id TEXT NOT NULL, remote_id TEXT, status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'migrated', 'failed', 'skipped' error_message TEXT, attempt_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), UNIQUE(entity_type, local_id) ); CREATE INDEX idx_migration_state_status ON migration_state(status); CREATE INDEX idx_migration_state_entity_type ON migration_state(entity_type); CREATE INDEX idx_migration_state_entity_lookup ON migration_state(entity_type, local_id); ================================================ FILE: crates/db/migrations/20260203000000_add_archive_script_to_repos.sql ================================================ -- Add archive_script column to repos table -- This script runs when a workspace is being archived ALTER TABLE repos ADD COLUMN archive_script TEXT; -- Add 'archivescript' to the run_reason CHECK constraint -- Note: The column was renamed from process_type to run_reason in migration 20250730000001 -- 1. Add the replacement column with the wider CHECK ALTER TABLE execution_processes ADD COLUMN run_reason_new TEXT NOT NULL DEFAULT 'setupscript' CHECK (run_reason_new IN ('setupscript', 'cleanupscript', 'archivescript', 'codingagent', 'devserver')); -- 2. Copy existing values across UPDATE execution_processes SET run_reason_new = run_reason; -- 3. Drop any indexes that reference run_reason DROP INDEX IF EXISTS idx_execution_processes_run_reason; DROP INDEX IF EXISTS idx_execution_processes_session_status_run_reason; DROP INDEX IF EXISTS idx_execution_processes_session_run_reason_created; -- 4. Remove the old column (requires 3.35+) ALTER TABLE execution_processes DROP COLUMN run_reason; -- 5. Rename the new column back to the canonical name ALTER TABLE execution_processes RENAME COLUMN run_reason_new TO run_reason; -- 6. Re-create all indexes CREATE INDEX idx_execution_processes_run_reason ON execution_processes(run_reason); CREATE INDEX idx_execution_processes_session_status_run_reason ON execution_processes (session_id, status, run_reason); CREATE INDEX idx_execution_processes_session_run_reason_created ON execution_processes (session_id, run_reason, created_at DESC); ================================================ FILE: crates/db/migrations/20260217120312_remove_task_fk_from_workspaces.sql ================================================ -- Remove FK constraint from workspaces.task_id → tasks(id). -- task_id column is preserved, just no longer FK-enforced. -- This breaks the ON DELETE CASCADE so deleting a task no longer deletes workspaces. -- sqlx workaround: end auto-transaction to allow PRAGMA to take effect COMMIT; PRAGMA foreign_keys = OFF; BEGIN TRANSACTION; CREATE TABLE workspaces_new ( id BLOB PRIMARY KEY, task_id BLOB, container_ref TEXT, branch TEXT NOT NULL, agent_working_dir TEXT, setup_completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), archived INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, name TEXT ); INSERT INTO workspaces_new (id, task_id, container_ref, branch, agent_working_dir, setup_completed_at, created_at, updated_at, archived, pinned, name) SELECT id, task_id, container_ref, branch, agent_working_dir, setup_completed_at, created_at, updated_at, archived, pinned, name FROM workspaces; DROP TABLE workspaces; ALTER TABLE workspaces_new RENAME TO workspaces; -- Recreate indexes (from 20250917 + 20251219 migrations) CREATE INDEX idx_workspaces_task_id_created_at ON workspaces (task_id, created_at DESC); CREATE INDEX idx_workspaces_created_at ON workspaces (created_at DESC); CREATE INDEX idx_workspaces_container_ref ON workspaces (container_ref) WHERE container_ref IS NOT NULL; -- Verify foreign key constraints before committing PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON; -- Create junction table for workspace-image associations (mirrors task_images) CREATE TABLE workspace_images ( id BLOB PRIMARY KEY, workspace_id BLOB NOT NULL, image_id BLOB NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, UNIQUE(workspace_id, image_id) ); CREATE INDEX idx_workspace_images_workspace_id ON workspace_images(workspace_id); CREATE INDEX idx_workspace_images_image_id ON workspace_images(image_id); -- Migrate existing task_images → workspace_images via workspaces.task_id INSERT INTO workspace_images (id, workspace_id, image_id, created_at) SELECT randomblob(16), w.id, ti.image_id, ti.created_at FROM task_images ti JOIN workspaces w ON w.task_id = ti.task_id; -- sqlx workaround: start empty transaction for sqlx to close gracefully BEGIN TRANSACTION; ================================================ FILE: crates/db/migrations/20260220000000_optimize_query_planner_after_latest_process_query_update.sql ================================================ -- Refresh SQLite planner statistics after recent query changes. PRAGMA optimize; ================================================ FILE: crates/db/migrations/20260302113031_add_worktree_deleted_to_workspaces.sql ================================================ -- Add worktree_deleted flag to track when worktrees are cleaned up ALTER TABLE workspaces ADD COLUMN worktree_deleted BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: crates/db/migrations/20260304153000_move_agent_working_dir_to_sessions.sql ================================================ -- Move agent_working_dir ownership from workspaces to sessions. -- Session working dir is backend-computed at session creation time. ALTER TABLE sessions ADD COLUMN agent_working_dir TEXT; -- Backfill existing sessions from workspace snapshot UPDATE sessions SET agent_working_dir = ( SELECT w.agent_working_dir FROM workspaces w WHERE w.id = sessions.workspace_id ); ALTER TABLE workspaces DROP COLUMN agent_working_dir; ================================================ FILE: crates/db/migrations/20260314000000_add_name_to_sessions.sql ================================================ -- Add name column to sessions table ALTER TABLE sessions ADD COLUMN name TEXT; ================================================ FILE: crates/db/migrations/20260317120000_cleanup_attachment_schema.sql ================================================ COMMIT; PRAGMA foreign_keys = OFF; BEGIN TRANSACTION; ALTER TABLE images RENAME TO attachments; ALTER TABLE task_images RENAME TO task_attachments; ALTER TABLE task_attachments RENAME COLUMN image_id TO attachment_id; ALTER TABLE workspace_images RENAME TO workspace_attachments; ALTER TABLE workspace_attachments RENAME COLUMN image_id TO attachment_id; DROP INDEX IF EXISTS idx_images_hash; DROP INDEX IF EXISTS idx_task_images_task_id; DROP INDEX IF EXISTS idx_task_images_image_id; DROP INDEX IF EXISTS idx_workspace_images_workspace_id; DROP INDEX IF EXISTS idx_workspace_images_image_id; CREATE INDEX idx_attachments_hash ON attachments(hash); CREATE INDEX idx_task_attachments_task_id ON task_attachments(task_id); CREATE INDEX idx_task_attachments_attachment_id ON task_attachments(attachment_id); CREATE INDEX idx_workspace_attachments_workspace_id ON workspace_attachments(workspace_id); CREATE INDEX idx_workspace_attachments_attachment_id ON workspace_attachments(attachment_id); PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON; BEGIN TRANSACTION; ================================================ FILE: crates/db/src/lib.rs ================================================ use std::{str::FromStr, sync::Arc}; use sqlx::{ ConnectOptions, Error, Pool, Sqlite, SqlitePool, migrate::MigrateError, sqlite::{SqliteConnectOptions, SqliteConnection, SqliteJournalMode, SqlitePoolOptions}, }; use utils::assets::asset_dir; pub mod models; async fn run_migrations(pool: &Pool) -> Result<(), Error> { use std::collections::HashSet; let migrator = sqlx::migrate!("./migrations"); let mut processed_versions: HashSet = HashSet::new(); loop { match migrator.run(pool).await { Ok(()) => return Ok(()), Err(MigrateError::VersionMismatch(version)) => { if cfg!(debug_assertions) { // return the error in debug mode to catch migration issues early return Err(sqlx::Error::Migrate(Box::new( MigrateError::VersionMismatch(version), ))); } if !cfg!(windows) { // On non-Windows platforms, we do not attempt to auto-fix checksum mismatches return Err(sqlx::Error::Migrate(Box::new( MigrateError::VersionMismatch(version), ))); } // Guard against infinite loop if !processed_versions.insert(version) { return Err(sqlx::Error::Migrate(Box::new( MigrateError::VersionMismatch(version), ))); } // On Windows, there can be checksum mismatches due to line ending differences // or other platform-specific issues. Update the stored checksum and retry. tracing::warn!( "Migration version {} has checksum mismatch, updating stored checksum (likely platform-specific difference)", version ); // Find the migration with the mismatched version and get its current checksum if let Some(migration) = migrator.iter().find(|m| m.version == version) { // Update the checksum in _sqlx_migrations to match the current file sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") .bind(&*migration.checksum) .bind(version) .execute(pool) .await?; } else { // Migration not found in current set, can't fix return Err(sqlx::Error::Migrate(Box::new( MigrateError::VersionMismatch(version), ))); } } Err(e) => return Err(e.into()), } } } #[derive(Clone)] pub struct DBService { pub pool: Pool, } impl DBService { pub async fn new() -> Result { let database_url = format!( "sqlite://{}", asset_dir().join("db.v2.sqlite").to_string_lossy() ); let options = SqliteConnectOptions::from_str(&database_url)? .create_if_missing(true) .journal_mode(SqliteJournalMode::Delete); let pool = SqlitePool::connect_with(options).await?; run_migrations(&pool).await?; Ok(DBService { pool }) } pub async fn new_migration_pool() -> Result, Error> { let database_url = format!( "sqlite://{}", asset_dir().join("db.v2.sqlite").to_string_lossy() ); let options = SqliteConnectOptions::from_str(&database_url)? .create_if_missing(true) .journal_mode(SqliteJournalMode::Delete) .disable_statement_logging(); SqlitePoolOptions::new() .max_connections(64) .connect_with(options) .await } pub async fn new_with_after_connect(after_connect: F) -> Result where F: for<'a> Fn( &'a mut SqliteConnection, ) -> std::pin::Pin< Box> + Send + 'a>, > + Send + Sync + 'static, { let pool = Self::create_pool(Some(Arc::new(after_connect))).await?; Ok(DBService { pool }) } async fn create_pool(after_connect: Option>) -> Result, Error> where F: for<'a> Fn( &'a mut SqliteConnection, ) -> std::pin::Pin< Box> + Send + 'a>, > + Send + Sync + 'static, { let database_url = format!( "sqlite://{}", asset_dir().join("db.v2.sqlite").to_string_lossy() ); let options = SqliteConnectOptions::from_str(&database_url)? .create_if_missing(true) .journal_mode(SqliteJournalMode::Delete); let pool = if let Some(hook) = after_connect { SqlitePoolOptions::new() .after_connect(move |conn, _meta| { let hook = hook.clone(); Box::pin(async move { hook(conn).await?; Ok(()) }) }) .connect_with(options) .await? } else { SqlitePool::connect_with(options).await? }; run_migrations(&pool).await?; Ok(pool) } } ================================================ FILE: crates/db/src/models/coding_agent_turn.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct CodingAgentTurn { pub id: Uuid, pub execution_process_id: Uuid, pub agent_session_id: Option, pub agent_message_id: Option, pub prompt: Option, // The prompt sent to the executor pub summary: Option, // Final assistant message/summary pub seen: bool, // Whether user has viewed this turn pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize, TS)] pub struct CreateCodingAgentTurn { pub execution_process_id: Uuid, pub prompt: Option, } /// Session info from a coding agent turn, used for follow-up requests #[derive(Debug)] pub struct CodingAgentResumeInfo { pub session_id: String, pub message_id: Option, } impl CodingAgentTurn { /// Find session info from the latest coding agent turn for a session. /// Only returns turns that have an agent_session_id set. pub async fn find_latest_session_info( pool: &SqlitePool, session_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( CodingAgentResumeInfo, r#"SELECT cat.agent_session_id as "session_id!", cat.agent_message_id as "message_id" FROM execution_processes ep JOIN coding_agent_turns cat ON ep.id = cat.execution_process_id WHERE ep.session_id = $1 AND ep.run_reason = 'codingagent' AND ep.dropped = FALSE AND cat.agent_session_id IS NOT NULL ORDER BY ep.created_at DESC LIMIT 1"#, session_id ) .fetch_optional(pool) .await } /// Find coding agent turn by execution process ID pub async fn find_by_execution_process_id( pool: &SqlitePool, execution_process_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( CodingAgentTurn, r#"SELECT id as "id!: Uuid", execution_process_id as "execution_process_id!: Uuid", agent_session_id, agent_message_id, prompt, summary, seen as "seen!: bool", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM coding_agent_turns WHERE execution_process_id = $1"#, execution_process_id ) .fetch_optional(pool) .await } pub async fn find_by_agent_session_id( pool: &SqlitePool, agent_session_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as!( CodingAgentTurn, r#"SELECT id as "id!: Uuid", execution_process_id as "execution_process_id!: Uuid", agent_session_id, agent_message_id, prompt, summary, seen as "seen!: bool", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM coding_agent_turns WHERE agent_session_id = ? ORDER BY updated_at DESC LIMIT 1"#, agent_session_id ) .fetch_optional(pool) .await } /// Create a new coding agent turn pub async fn create( pool: &SqlitePool, data: &CreateCodingAgentTurn, id: Uuid, ) -> Result { let now = Utc::now(); tracing::debug!( "Creating coding agent turn: id={}, execution_process_id={}, agent_session_id=None (will be set later)", id, data.execution_process_id ); sqlx::query_as!( CodingAgentTurn, r#"INSERT INTO coding_agent_turns ( id, execution_process_id, agent_session_id, agent_message_id, prompt, summary, seen, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id as "id!: Uuid", execution_process_id as "execution_process_id!: Uuid", agent_session_id, agent_message_id, prompt, summary, seen as "seen!: bool", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, data.execution_process_id, None::, // agent_session_id initially None until parsed from output None::, // agent_message_id initially None until parsed from output data.prompt, None::, // summary initially None false, // seen - defaults to unseen now, // created_at now // updated_at ) .fetch_one(pool) .await } /// Update coding agent turn with agent session ID pub async fn update_agent_session_id( pool: &SqlitePool, execution_process_id: Uuid, agent_session_id: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE coding_agent_turns SET agent_session_id = $1, updated_at = $2 WHERE execution_process_id = $3"#, agent_session_id, now, execution_process_id ) .execute(pool) .await?; Ok(()) } /// Update coding agent turn with agent message ID (for --resume-session-at) pub async fn update_agent_message_id( pool: &SqlitePool, execution_process_id: Uuid, agent_message_id: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE coding_agent_turns SET agent_message_id = $1, updated_at = $2 WHERE execution_process_id = $3"#, agent_message_id, now, execution_process_id ) .execute(pool) .await?; Ok(()) } /// Update coding agent turn summary pub async fn update_summary( pool: &SqlitePool, execution_process_id: Uuid, summary: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE coding_agent_turns SET summary = $1, updated_at = $2 WHERE execution_process_id = $3"#, summary, now, execution_process_id ) .execute(pool) .await?; Ok(()) } /// Mark a coding agent turn as unseen by execution process ID. pub async fn mark_unseen_by_execution_process_id( pool: &SqlitePool, execution_process_id: Uuid, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query( r#"UPDATE coding_agent_turns SET seen = 0, updated_at = ? WHERE execution_process_id = ? AND seen = 1"#, ) .bind(now) .bind(execution_process_id.to_string()) .execute(pool) .await?; Ok(()) } /// Mark all coding agent turns for a workspace as seen pub async fn mark_seen_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE coding_agent_turns SET seen = 1, updated_at = $1 WHERE execution_process_id IN ( SELECT ep.id FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id WHERE s.workspace_id = $2 ) AND seen = 0"#, now, workspace_id ) .execute(pool) .await?; Ok(()) } /// Check if a workspace has any unseen coding agent turns pub async fn has_unseen_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result { let result = sqlx::query_scalar!( r#"SELECT EXISTS( SELECT 1 FROM coding_agent_turns cat JOIN execution_processes ep ON cat.execution_process_id = ep.id JOIN sessions s ON ep.session_id = s.id WHERE s.workspace_id = $1 AND cat.seen = 0 ) as "has_unseen!: bool""#, workspace_id ) .fetch_one(pool) .await?; Ok(result) } /// Find all workspaces that have unseen coding agent turns, filtered by archived status pub async fn find_workspaces_with_unseen( pool: &SqlitePool, archived: bool, ) -> Result, sqlx::Error> { let result: Vec = sqlx::query_scalar!( r#"SELECT DISTINCT s.workspace_id as "workspace_id!: Uuid" FROM coding_agent_turns cat JOIN execution_processes ep ON cat.execution_process_id = ep.id JOIN sessions s ON ep.session_id = s.id JOIN workspaces w ON s.workspace_id = w.id WHERE cat.seen = 0 AND w.archived = $1"#, archived ) .fetch_all(pool) .await?; Ok(result.into_iter().collect()) } } ================================================ FILE: crates/db/src/models/execution_process.rs ================================================ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use executors::{ actions::{ExecutorAction, ExecutorActionType}, profile::ExecutorProfileId, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::{FromRow, SqlitePool, Type}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; use super::{ execution_process_repo_state::{CreateExecutionProcessRepoState, ExecutionProcessRepoState}, repo::Repo, session::Session, workspace::Workspace, workspace_repo::WorkspaceRepo, }; #[derive(Debug, Error)] pub enum ExecutionProcessError { #[error(transparent)] Database(#[from] sqlx::Error), #[error("Execution process not found")] ExecutionProcessNotFound, #[error("Failed to create execution process: {0}")] CreateFailed(String), #[error("Failed to update execution process: {0}")] UpdateFailed(String), #[error("Invalid executor action format")] InvalidExecutorAction, #[error("Validation error: {0}")] ValidationError(String), } #[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)] #[sqlx(type_name = "execution_process_status", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] #[ts(use_ts_enum)] pub enum ExecutionProcessStatus { Running, Completed, Failed, Killed, } #[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS)] #[sqlx(type_name = "execution_process_run_reason", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum ExecutionProcessRunReason { SetupScript, CleanupScript, ArchiveScript, CodingAgent, DevServer, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct ExecutionProcess { pub id: Uuid, pub session_id: Uuid, pub run_reason: ExecutionProcessRunReason, #[ts(type = "ExecutorAction")] pub executor_action: sqlx::types::Json, pub status: ExecutionProcessStatus, pub exit_code: Option, /// dropped: true if this process is excluded from the current /// history view (due to restore/trimming). Hidden from logs/timeline; /// still listed in the Processes tab. pub dropped: bool, pub started_at: DateTime, pub completed_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize, TS)] pub struct CreateExecutionProcess { pub session_id: Uuid, pub executor_action: ExecutorAction, pub run_reason: ExecutionProcessRunReason, } #[derive(Debug, Deserialize, TS)] #[allow(dead_code)] pub struct UpdateExecutionProcess { pub status: Option, pub exit_code: Option, pub completed_at: Option>, } #[derive(Debug)] pub struct ExecutionContext { pub execution_process: ExecutionProcess, pub session: Session, pub workspace: Workspace, pub repos: Vec, } /// Summary info about the latest execution process for a workspace #[derive(Debug, Clone, FromRow)] pub struct LatestProcessInfo { pub workspace_id: Uuid, pub execution_process_id: Uuid, pub session_id: Uuid, pub status: ExecutionProcessStatus, pub completed_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum ExecutorActionField { ExecutorAction(ExecutorAction), Other(Value), } #[derive(Debug, Clone)] pub struct MissingBeforeContext { pub id: Uuid, pub session_id: Uuid, pub workspace_id: Uuid, pub repo_id: Uuid, pub prev_after_head_commit: Option, pub target_branch: String, pub repo_path: Option, } impl ExecutionProcess { /// Find execution process by ID pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.id = ?"#, id ) .fetch_optional(pool) .await } /// Context for backfilling before_head_commit for legacy rows /// List processes that have after_head_commit set but missing before_head_commit, with join context pub async fn list_missing_before_context( pool: &SqlitePool, ) -> Result, sqlx::Error> { let rows = sqlx::query!( r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", s.workspace_id as "workspace_id!: Uuid", eprs.repo_id as "repo_id!: Uuid", eprs.after_head_commit as after_head_commit, prev.after_head_commit as prev_after_head_commit, wr.target_branch as "target_branch!", r.path as repo_path FROM execution_processes ep JOIN sessions s ON s.id = ep.session_id JOIN execution_process_repo_states eprs ON eprs.execution_process_id = ep.id JOIN repos r ON r.id = eprs.repo_id JOIN workspaces w ON w.id = s.workspace_id JOIN workspace_repos wr ON wr.workspace_id = w.id AND wr.repo_id = eprs.repo_id LEFT JOIN execution_process_repo_states prev ON prev.execution_process_id = ( SELECT id FROM execution_processes WHERE session_id = ep.session_id AND created_at < ep.created_at ORDER BY created_at DESC LIMIT 1 ) AND prev.repo_id = eprs.repo_id WHERE eprs.before_head_commit IS NULL AND eprs.after_head_commit IS NOT NULL"# ) .fetch_all(pool) .await?; let result = rows .into_iter() .map(|r| MissingBeforeContext { id: r.id, session_id: r.session_id, workspace_id: r.workspace_id, repo_id: r.repo_id, prev_after_head_commit: r.prev_after_head_commit, target_branch: r.target_branch, repo_path: Some(r.repo_path), }) .collect(); Ok(result) } /// Find execution process by rowid pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.rowid = ?"#, rowid ) .fetch_optional(pool) .await } /// Find all execution processes for a session (optionally include soft-deleted) pub async fn find_by_session_id( pool: &SqlitePool, session_id: Uuid, show_soft_deleted: bool, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.session_id = ? AND (? OR ep.dropped = FALSE) ORDER BY ep.created_at ASC"#, session_id, show_soft_deleted ) .fetch_all(pool) .await } /// Find running execution processes pub async fn find_running(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.status = 'running' ORDER BY ep.created_at ASC"#, ) .fetch_all(pool) .await } /// Check if there's a running coding agent process for a session pub async fn has_running_coding_agent_for_session( pool: &SqlitePool, session_id: Uuid, ) -> Result { let count: i64 = sqlx::query_scalar!( r#"SELECT COUNT(*) as "count!: i64" FROM execution_processes ep WHERE ep.session_id = $1 AND ep.status = 'running' AND ep.run_reason = 'codingagent'"#, session_id ) .fetch_one(pool) .await?; Ok(count > 0) } /// Check if there are running processes (excluding dev servers) for a workspace (across all sessions) pub async fn has_running_non_dev_server_processes_for_workspace( pool: &SqlitePool, workspace_id: Uuid, ) -> Result { let count: i64 = sqlx::query_scalar!( r#"SELECT COUNT(*) as "count!: i64" FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id WHERE s.workspace_id = $1 AND ep.status = 'running' AND ep.run_reason != 'devserver'"#, workspace_id ) .fetch_one(pool) .await?; Ok(count > 0) } /// Find running dev servers for a specific workspace (across all sessions) pub async fn find_running_dev_servers_by_workspace( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#" SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id WHERE s.workspace_id = ? AND ep.status = 'running' AND ep.run_reason = 'devserver' ORDER BY ep.created_at DESC "#, workspace_id ) .fetch_all(pool) .await } /// Find latest execution process by session and run reason pub async fn find_latest_by_session_and_run_reason( pool: &SqlitePool, session_id: Uuid, run_reason: &ExecutionProcessRunReason, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE ORDER BY ep.created_at DESC LIMIT 1"#, session_id, run_reason ) .fetch_optional(pool) .await } /// Find latest execution process by workspace and run reason (across all sessions) pub async fn find_latest_by_workspace_and_run_reason( pool: &SqlitePool, workspace_id: Uuid, run_reason: &ExecutionProcessRunReason, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id WHERE s.workspace_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE ORDER BY ep.created_at DESC LIMIT 1"#, workspace_id, run_reason ) .fetch_optional(pool) .await } /// Create a new execution process /// /// Note: We intentionally avoid using a transaction here. SQLite update /// hooks fire during transactions (before commit), and the hook spawns an /// async task that queries `find_by_rowid` on a different connection. /// If we used a transaction, that query would not see the uncommitted row, /// causing the WebSocket event to be lost. pub async fn create( pool: &SqlitePool, data: &CreateExecutionProcess, process_id: Uuid, repo_states: &[CreateExecutionProcessRepoState], ) -> Result { let now = Utc::now(); let executor_action_json = sqlx::types::Json(&data.executor_action); sqlx::query!( r#"INSERT INTO execution_processes ( id, session_id, run_reason, executor_action, status, exit_code, started_at, completed_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, process_id, data.session_id, data.run_reason, executor_action_json, ExecutionProcessStatus::Running, None::, now, None::>, now, now ) .execute(pool) .await?; ExecutionProcessRepoState::create_many(pool, process_id, repo_states).await?; Self::find_by_id(pool, process_id) .await? .ok_or(sqlx::Error::RowNotFound) } pub async fn was_stopped(pool: &SqlitePool, id: Uuid) -> bool { if let Ok(exp_process) = Self::find_by_id(pool, id).await && exp_process.is_some_and(|ep| { ep.status == ExecutionProcessStatus::Killed || ep.status == ExecutionProcessStatus::Completed }) { return true; } false } /// Update execution process status and completion info pub async fn update_completion( pool: &SqlitePool, id: Uuid, status: ExecutionProcessStatus, exit_code: Option, ) -> Result<(), sqlx::Error> { let completed_at = if matches!(status, ExecutionProcessStatus::Running) { None } else { Some(Utc::now()) }; sqlx::query!( r#"UPDATE execution_processes SET status = $1, exit_code = $2, completed_at = $3 WHERE id = $4"#, status, exit_code, completed_at, id ) .execute(pool) .await?; Ok(()) } pub fn executor_action(&self) -> Result<&ExecutorAction, anyhow::Error> { match &self.executor_action.0 { ExecutorActionField::ExecutorAction(action) => Ok(action), ExecutorActionField::Other(_) => Err(anyhow::anyhow!( "Executor action is not a valid ExecutorAction JSON object" )), } } /// Soft-drop processes at and after the specified boundary (inclusive) pub async fn drop_at_and_after( pool: &SqlitePool, session_id: Uuid, boundary_process_id: Uuid, ) -> Result { let result = sqlx::query!( r#"UPDATE execution_processes SET dropped = TRUE WHERE session_id = $1 AND created_at >= (SELECT created_at FROM execution_processes WHERE id = $2) AND dropped = FALSE"#, session_id, boundary_process_id ) .execute(pool) .await?; Ok(result.rows_affected() as i64) } /// Find the previous process's after_head_commit before the given boundary process /// for a specific repository pub async fn find_prev_after_head_commit( pool: &SqlitePool, session_id: Uuid, boundary_process_id: Uuid, repo_id: Uuid, ) -> Result, sqlx::Error> { let result = sqlx::query_scalar!( r#"SELECT eprs.after_head_commit FROM execution_process_repo_states eprs JOIN execution_processes ep ON ep.id = eprs.execution_process_id WHERE ep.session_id = $1 AND eprs.repo_id = $2 AND ep.created_at < (SELECT created_at FROM execution_processes WHERE id = $3) ORDER BY ep.created_at DESC LIMIT 1"#, session_id, repo_id, boundary_process_id ) .fetch_optional(pool) .await?; Ok(result.flatten()) } /// Get the parent Session for this execution process pub async fn parent_session(&self, pool: &SqlitePool) -> Result, sqlx::Error> { Session::find_by_id(pool, self.session_id).await } /// Get both the parent Workspace and Session for this execution process pub async fn parent_workspace_and_session( &self, pool: &SqlitePool, ) -> Result, sqlx::Error> { let session = match Session::find_by_id(pool, self.session_id).await? { Some(s) => s, None => return Ok(None), }; let workspace = match Workspace::find_by_id(pool, session.workspace_id).await? { Some(w) => w, None => return Ok(None), }; Ok(Some((workspace, session))) } /// Load execution context with related session, workspace, task, project, and repos pub async fn load_context( pool: &SqlitePool, exec_id: Uuid, ) -> Result { let execution_process = Self::find_by_id(pool, exec_id) .await? .ok_or(sqlx::Error::RowNotFound)?; let session = Session::find_by_id(pool, execution_process.session_id) .await? .ok_or(sqlx::Error::RowNotFound)?; let workspace = Workspace::find_by_id(pool, session.workspace_id) .await? .ok_or(sqlx::Error::RowNotFound)?; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; Ok(ExecutionContext { execution_process, session, workspace, repos, }) } /// Fetch the latest CodingAgent executor profile for a session. /// Returns None if no CodingAgent execution process exists for this session. pub async fn latest_executor_profile_for_session( pool: &SqlitePool, session_id: Uuid, ) -> Result, ExecutionProcessError> { // Find the latest CodingAgent execution process for this session let latest_execution_process = sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", ep.session_id as "session_id!: Uuid", ep.run_reason as "run_reason!: ExecutionProcessRunReason", ep.executor_action as "executor_action!: sqlx::types::Json", ep.status as "status!: ExecutionProcessStatus", ep.exit_code, ep.dropped as "dropped!: bool", ep.started_at as "started_at!: DateTime", ep.completed_at as "completed_at?: DateTime", ep.created_at as "created_at!: DateTime", ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep WHERE ep.session_id = ? AND ep.run_reason = ? AND ep.dropped = FALSE ORDER BY ep.created_at DESC LIMIT 1"#, session_id, ExecutionProcessRunReason::CodingAgent ) .fetch_optional(pool) .await?; let Some(latest_execution_process) = latest_execution_process else { return Ok(None); }; let action = latest_execution_process .executor_action() .map_err(|e| ExecutionProcessError::ValidationError(e.to_string()))?; match &action.typ { ExecutorActionType::CodingAgentInitialRequest(request) => { Ok(Some(request.executor_config.profile_id())) } ExecutorActionType::CodingAgentFollowUpRequest(request) => { Ok(Some(request.executor_config.profile_id())) } ExecutorActionType::ReviewRequest(request) => { Ok(Some(request.executor_config.profile_id())) } _ => Err(ExecutionProcessError::ValidationError( "Couldn't find profile from initial request".to_string(), )), } } /// Fetch latest execution process info for all workspaces with the given archived status. /// Returns a map of workspace_id -> LatestProcessInfo for the most recent /// non-dropped execution process (excluding dev servers). pub async fn find_latest_for_workspaces( pool: &SqlitePool, archived: bool, ) -> Result, sqlx::Error> { let rows: Vec = sqlx::query_as!( LatestProcessInfo, r#" SELECT workspace_id as "workspace_id!: Uuid", execution_process_id as "execution_process_id!: Uuid", session_id as "session_id!: Uuid", status as "status!: ExecutionProcessStatus", completed_at as "completed_at?: DateTime" FROM ( SELECT s.workspace_id, ep.id as execution_process_id, ep.session_id, ep.status, ep.completed_at, ROW_NUMBER() OVER ( PARTITION BY s.workspace_id ORDER BY ep.created_at DESC ) as rn FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id JOIN workspaces w ON s.workspace_id = w.id WHERE w.archived = $1 AND ep.run_reason IN ('codingagent', 'setupscript', 'cleanupscript') AND ep.dropped = FALSE ) WHERE rn = 1 "#, archived ) .fetch_all(pool) .await?; let result = rows .into_iter() .map(|info| (info.workspace_id, info)) .collect(); Ok(result) } /// Find all workspaces with running dev servers, filtered by archived status. /// Returns a set of workspace IDs that have at least one running dev server. pub async fn find_workspaces_with_running_dev_servers( pool: &SqlitePool, archived: bool, ) -> Result, sqlx::Error> { let rows: Vec = sqlx::query_scalar!( r#" SELECT DISTINCT s.workspace_id as "workspace_id!: Uuid" FROM execution_processes ep JOIN sessions s ON ep.session_id = s.id JOIN workspaces w ON s.workspace_id = w.id WHERE w.archived = $1 AND ep.status = 'running' AND ep.run_reason = 'devserver' "#, archived ) .fetch_all(pool) .await?; Ok(rows.into_iter().collect()) } } ================================================ FILE: crates/db/src/models/execution_process_logs.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use utils::log_msg::LogMsg; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct ExecutionProcessLogs { pub execution_id: Uuid, pub logs: String, // JSONL format pub byte_size: i64, pub inserted_at: DateTime, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct ExecutionProcessLogMigrationInfo { pub execution_id: Uuid, pub session_id: Uuid, } impl ExecutionProcessLogs { /// Check if there are any log records pub async fn has_any(pool: &SqlitePool) -> Result { let result: Option = match sqlx::query_scalar("SELECT 1 FROM execution_process_logs LIMIT 1") .fetch_optional(pool) .await { Ok(r) => r, Err(sqlx::Error::Database(e)) if e.message().contains("no such table") => { return Ok(false); } Err(e) => return Err(e), }; Ok(result.is_some()) } /// Count the total number of distinct execution processes that have logs pub async fn count_distinct_processes(pool: &SqlitePool) -> Result { let count: i64 = sqlx::query_scalar( r#" SELECT COUNT(id) FROM execution_processes ep WHERE EXISTS ( SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id ) "#, ) .fetch_one(pool) .await?; Ok(count) } /// Get a stream of distinct execution processes that have logs pub fn stream_distinct_processes<'a>( pool: &'a SqlitePool, ) -> futures::stream::BoxStream<'a, Result> { sqlx::query_as!( ExecutionProcessLogMigrationInfo, r#" SELECT ep.id as "execution_id!: Uuid", ep.session_id as "session_id!: Uuid" FROM execution_processes ep WHERE EXISTS ( SELECT 1 FROM execution_process_logs epl WHERE epl.execution_id = ep.id ) "# ) .fetch(pool) } /// Delete all records in execution_process_logs pub async fn delete_all(pool: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM execution_process_logs") .execute(pool) .await?; Ok(()) } /// Find logs by execution process ID pub async fn find_by_execution_id( pool: &SqlitePool, execution_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcessLogs, r#"SELECT execution_id as "execution_id!: Uuid", logs, byte_size, inserted_at as "inserted_at!: DateTime" FROM execution_process_logs WHERE execution_id = $1 ORDER BY inserted_at ASC"#, execution_id ) .fetch_all(pool) .await } /// Find logs by execution process ID as a stream of strings pub fn stream_log_lines_by_execution_id<'a>( pool: &'a SqlitePool, execution_id: &'a Uuid, ) -> futures::stream::BoxStream<'a, Result> { sqlx::query_scalar!( r#"SELECT logs FROM execution_process_logs WHERE execution_id = $1 ORDER BY inserted_at ASC"#, *execution_id ) .fetch(pool) } /// Parse JSONL logs back into Vec pub fn parse_logs(records: &[Self]) -> Result, serde_json::Error> { let mut messages = Vec::new(); for line in records.iter().flat_map(|record| record.logs.lines()) { if !line.trim().is_empty() { let msg: LogMsg = serde_json::from_str(line)?; messages.push(msg); } } Ok(messages) } /// Append a JSONL line to the logs for an execution process pub async fn append_log_line( pool: &SqlitePool, execution_id: Uuid, jsonl_line: &str, ) -> Result<(), sqlx::Error> { let byte_size = jsonl_line.len() as i64; sqlx::query!( r#"INSERT INTO execution_process_logs (execution_id, logs, byte_size, inserted_at) VALUES ($1, $2, $3, datetime('now', 'subsec'))"#, execution_id, jsonl_line, byte_size ) .execute(pool) .await?; Ok(()) } } ================================================ FILE: crates/db/src/models/execution_process_repo_state.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct ExecutionProcessRepoState { pub id: Uuid, pub execution_process_id: Uuid, pub repo_id: Uuid, pub before_head_commit: Option, pub after_head_commit: Option, pub merge_commit: Option, #[ts(type = "Date")] pub created_at: DateTime, #[ts(type = "Date")] pub updated_at: DateTime, } #[derive(Debug, Clone)] pub struct CreateExecutionProcessRepoState { pub repo_id: Uuid, pub before_head_commit: Option, pub after_head_commit: Option, pub merge_commit: Option, } impl ExecutionProcessRepoState { pub async fn create_many( pool: &SqlitePool, execution_process_id: Uuid, entries: &[CreateExecutionProcessRepoState], ) -> Result<(), sqlx::Error> { if entries.is_empty() { return Ok(()); } let now = Utc::now(); for entry in entries { let id = Uuid::new_v4(); sqlx::query!( r#"INSERT INTO execution_process_repo_states ( id, execution_process_id, repo_id, before_head_commit, after_head_commit, merge_commit, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#, id, execution_process_id, entry.repo_id, entry.before_head_commit, entry.after_head_commit, entry.merge_commit, now, now ) .execute(pool) .await?; } Ok(()) } pub async fn update_before_head_commit( pool: &SqlitePool, execution_process_id: Uuid, repo_id: Uuid, before_head_commit: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE execution_process_repo_states SET before_head_commit = $1, updated_at = $2 WHERE execution_process_id = $3 AND repo_id = $4"#, before_head_commit, now, execution_process_id, repo_id ) .execute(pool) .await?; Ok(()) } pub async fn update_after_head_commit( pool: &SqlitePool, execution_process_id: Uuid, repo_id: Uuid, after_head_commit: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE execution_process_repo_states SET after_head_commit = $1, updated_at = $2 WHERE execution_process_id = $3 AND repo_id = $4"#, after_head_commit, now, execution_process_id, repo_id ) .execute(pool) .await?; Ok(()) } pub async fn set_merge_commit( pool: &SqlitePool, execution_process_id: Uuid, repo_id: Uuid, merge_commit: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( r#"UPDATE execution_process_repo_states SET merge_commit = $1, updated_at = $2 WHERE execution_process_id = $3 AND repo_id = $4"#, merge_commit, now, execution_process_id, repo_id ) .execute(pool) .await?; Ok(()) } pub async fn find_by_execution_process_id( pool: &SqlitePool, execution_process_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( ExecutionProcessRepoState, r#"SELECT id as "id!: Uuid", execution_process_id as "execution_process_id!: Uuid", repo_id as "repo_id!: Uuid", before_head_commit, after_head_commit, merge_commit, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM execution_process_repo_states WHERE execution_process_id = $1 ORDER BY created_at ASC"#, execution_process_id ) .fetch_all(pool) .await } } ================================================ FILE: crates/db/src/models/file.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct File { pub id: Uuid, pub file_path: String, // relative path within cache/attachments/ pub original_name: String, pub mime_type: Option, pub size_bytes: i64, pub hash: String, // SHA256 hash for deduplication pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize, TS)] pub struct CreateFile { pub file_path: String, pub original_name: String, pub mime_type: Option, pub size_bytes: i64, pub hash: String, } impl File { pub async fn create(pool: &SqlitePool, data: &CreateFile) -> Result { let id = Uuid::new_v4(); sqlx::query_as!( File, r#"INSERT INTO attachments (id, file_path, original_name, mime_type, size_bytes, hash) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id as "id!: Uuid", file_path as "file_path!", original_name as "original_name!", mime_type, size_bytes as "size_bytes!", hash as "hash!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, data.file_path, data.original_name, data.mime_type, data.size_bytes, data.hash, ) .fetch_one(pool) .await } pub async fn find_by_hash(pool: &SqlitePool, hash: &str) -> Result, sqlx::Error> { sqlx::query_as!( File, r#"SELECT id as "id!: Uuid", file_path as "file_path!", original_name as "original_name!", mime_type, size_bytes as "size_bytes!", hash as "hash!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM attachments WHERE hash = $1"#, hash ) .fetch_optional(pool) .await } pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( File, r#"SELECT id as "id!: Uuid", file_path as "file_path!", original_name as "original_name!", mime_type, size_bytes as "size_bytes!", hash as "hash!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM attachments WHERE id = $1"#, id ) .fetch_optional(pool) .await } pub async fn find_by_file_path( pool: &SqlitePool, file_path: &str, ) -> Result, sqlx::Error> { sqlx::query_as!( File, r#"SELECT id as "id!: Uuid", file_path as "file_path!", original_name as "original_name!", mime_type, size_bytes as "size_bytes!", hash as "hash!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM attachments WHERE file_path = $1"#, file_path ) .fetch_optional(pool) .await } pub async fn find_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( File, r#"SELECT i.id as "id!: Uuid", i.file_path as "file_path!", i.original_name as "original_name!", i.mime_type, i.size_bytes as "size_bytes!", i.hash as "hash!", i.created_at as "created_at!: DateTime", i.updated_at as "updated_at!: DateTime" FROM attachments i JOIN workspace_attachments wa ON i.id = wa.attachment_id WHERE wa.workspace_id = $1 ORDER BY wa.created_at"#, workspace_id ) .fetch_all(pool) .await } pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!(r#"DELETE FROM attachments WHERE id = $1"#, id) .execute(pool) .await?; Ok(()) } pub async fn find_orphaned_files(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( File, r#"SELECT i.id as "id!: Uuid", i.file_path as "file_path!", i.original_name as "original_name!", i.mime_type, i.size_bytes as "size_bytes!", i.hash as "hash!", i.created_at as "created_at!: DateTime", i.updated_at as "updated_at!: DateTime" FROM attachments i LEFT JOIN workspace_attachments wa ON i.id = wa.attachment_id WHERE wa.workspace_id IS NULL"# ) .fetch_all(pool) .await } } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct WorkspaceAttachment { pub id: Uuid, pub workspace_id: Uuid, pub attachment_id: Uuid, pub created_at: DateTime, } impl WorkspaceAttachment { /// Associate multiple attachments with a workspace, skipping duplicates. pub async fn associate_many_dedup( pool: &SqlitePool, workspace_id: Uuid, attachment_ids: &[Uuid], ) -> Result<(), sqlx::Error> { for &attachment_id in attachment_ids { let id = Uuid::new_v4(); sqlx::query!( r#"INSERT INTO workspace_attachments (id, workspace_id, attachment_id) SELECT $1, $2, $3 WHERE NOT EXISTS ( SELECT 1 FROM workspace_attachments WHERE workspace_id = $2 AND attachment_id = $3 )"#, id, workspace_id, attachment_id ) .execute(pool) .await?; } Ok(()) } } ================================================ FILE: crates/db/src/models/merge.rs ================================================ use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool, Type}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, TS, Type)] #[sqlx(type_name = "merge_status", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum MergeStatus { Open, Merged, Closed, Unknown, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Merge { Direct(DirectMerge), Pr(PrMerge), } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DirectMerge { pub id: Uuid, pub workspace_id: Uuid, pub repo_id: Uuid, pub merge_commit: String, pub target_branch_name: String, pub created_at: DateTime, } /// PR merge - represents a pull request merge #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PrMerge { pub id: Uuid, pub workspace_id: Uuid, pub repo_id: Uuid, pub created_at: DateTime, pub target_branch_name: String, pub pr_info: PullRequestInfo, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PullRequestInfo { pub number: i64, pub url: String, pub status: MergeStatus, pub merged_at: Option>, pub merge_commit_sha: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[sqlx(type_name = "TEXT", rename_all = "snake_case")] pub enum MergeType { Direct, Pr, } #[derive(FromRow)] struct MergeRow { id: Uuid, workspace_id: Uuid, repo_id: Uuid, merge_type: MergeType, merge_commit: Option, target_branch_name: String, pr_number: Option, pr_url: Option, pr_status: Option, pr_merged_at: Option>, pr_merge_commit_sha: Option, created_at: DateTime, } impl Merge { pub fn merge_commit(&self) -> Option { match self { Merge::Direct(direct) => Some(direct.merge_commit.clone()), Merge::Pr(pr) => pr.pr_info.merge_commit_sha.clone(), } } /// Create a direct merge record pub async fn create_direct( pool: &SqlitePool, workspace_id: Uuid, repo_id: Uuid, target_branch_name: &str, merge_commit: &str, ) -> Result { let id = Uuid::new_v4(); let now = Utc::now(); sqlx::query_as!( MergeRow, r#"INSERT INTO merges ( id, workspace_id, repo_id, merge_type, merge_commit, created_at, target_branch_name ) VALUES ($1, $2, $3, 'direct', $4, $5, $6) RETURNING id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, created_at as "created_at!: DateTime", target_branch_name as "target_branch_name!: String" "#, id, workspace_id, repo_id, merge_commit, now, target_branch_name ) .fetch_one(pool) .await .map(Into::into) } /// Create a new PR record (when PR is opened) pub async fn create_pr( pool: &SqlitePool, workspace_id: Uuid, repo_id: Uuid, target_branch_name: &str, pr_number: i64, pr_url: &str, ) -> Result { let id = Uuid::new_v4(); let now = Utc::now(); sqlx::query_as!( MergeRow, r#"INSERT INTO merges ( id, workspace_id, repo_id, merge_type, pr_number, pr_url, pr_status, created_at, target_branch_name ) VALUES ($1, $2, $3, 'pr', $4, $5, 'open', $6, $7) RETURNING id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, created_at as "created_at!: DateTime", target_branch_name as "target_branch_name!: String" "#, id, workspace_id, repo_id, pr_number, pr_url, now, target_branch_name ) .fetch_one(pool) .await .map(Into::into) } pub async fn find_all_pr(pool: &SqlitePool) -> Result, sqlx::Error> { let rows = sqlx::query_as!( MergeRow, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, created_at as "created_at!: DateTime", target_branch_name as "target_branch_name!: String" FROM merges WHERE merge_type = 'pr' ORDER BY created_at ASC"#, ) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn get_open_prs(pool: &SqlitePool) -> Result, sqlx::Error> { let rows = sqlx::query_as!( MergeRow, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, created_at as "created_at!: DateTime", target_branch_name as "target_branch_name!: String" FROM merges WHERE merge_type = 'pr' AND pr_status = 'open' ORDER BY created_at DESC"#, ) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn count_open_prs_for_workspace( pool: &SqlitePool, workspace_id: Uuid, ) -> Result { let count = sqlx::query_scalar!( r#"SELECT COUNT(1) as "count!: i64" FROM merges WHERE workspace_id = $1 AND merge_type = 'pr' AND pr_status = 'open'"#, workspace_id ) .fetch_one(pool) .await?; Ok(count) } /// Update PR status for a workspace pub async fn update_status( pool: &SqlitePool, merge_id: Uuid, pr_status: MergeStatus, merge_commit_sha: Option, ) -> Result<(), sqlx::Error> { let merged_at = if matches!(pr_status, MergeStatus::Merged) { Some(Utc::now()) } else { None }; sqlx::query!( r#"UPDATE merges SET pr_status = $1, pr_merge_commit_sha = $2, pr_merged_at = $3 WHERE id = $4"#, pr_status, merge_commit_sha, merged_at, merge_id ) .execute(pool) .await?; Ok(()) } /// Find all merges for a workspace (returns both direct and PR merges) pub async fn find_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { // Get raw data from database let rows = sqlx::query_as!( MergeRow, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, target_branch_name as "target_branch_name!: String", created_at as "created_at!: DateTime" FROM merges WHERE workspace_id = $1 ORDER BY created_at DESC"#, workspace_id ) .fetch_all(pool) .await?; // Convert to appropriate types based on merge_type Ok(rows.into_iter().map(Into::into).collect()) } /// Find all merges for a workspace and specific repo pub async fn find_by_workspace_and_repo_id( pool: &SqlitePool, workspace_id: Uuid, repo_id: Uuid, ) -> Result, sqlx::Error> { let rows = sqlx::query_as!( MergeRow, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", merge_type as "merge_type!: MergeType", merge_commit, pr_number, pr_url, pr_status as "pr_status?: MergeStatus", pr_merged_at as "pr_merged_at?: DateTime", pr_merge_commit_sha, target_branch_name as "target_branch_name!: String", created_at as "created_at!: DateTime" FROM merges WHERE workspace_id = $1 AND repo_id = $2 ORDER BY created_at DESC"#, workspace_id, repo_id ) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } /// Get the latest PR for each workspace (for workspace summaries) /// Returns a map of workspace_id -> PrMerge for workspaces that have PRs pub async fn get_latest_pr_status_for_workspaces( pool: &SqlitePool, archived: bool, ) -> Result, sqlx::Error> { // Get the latest PR for each workspace by using a subquery to find the max created_at // Only consider PR merges (not direct merges) let rows = sqlx::query_as!( MergeRow, r#"SELECT m.id as "id!: Uuid", m.workspace_id as "workspace_id!: Uuid", m.repo_id as "repo_id!: Uuid", m.merge_type as "merge_type!: MergeType", m.merge_commit, m.pr_number, m.pr_url, m.pr_status as "pr_status?: MergeStatus", m.pr_merged_at as "pr_merged_at?: DateTime", m.pr_merge_commit_sha, m.target_branch_name as "target_branch_name!: String", m.created_at as "created_at!: DateTime" FROM merges m INNER JOIN ( SELECT workspace_id, MAX(created_at) as max_created_at FROM merges WHERE merge_type = 'pr' GROUP BY workspace_id ) latest ON m.workspace_id = latest.workspace_id AND m.created_at = latest.max_created_at INNER JOIN workspaces w ON m.workspace_id = w.id WHERE m.merge_type = 'pr' AND w.archived = $1"#, archived ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| { let workspace_id = row.workspace_id; (workspace_id, PrMerge::from(row)) }) .collect()) } } // Conversion implementations impl From for DirectMerge { fn from(row: MergeRow) -> Self { DirectMerge { id: row.id, workspace_id: row.workspace_id, repo_id: row.repo_id, merge_commit: row .merge_commit .expect("direct merge must have merge_commit"), target_branch_name: row.target_branch_name, created_at: row.created_at, } } } impl From for PrMerge { fn from(row: MergeRow) -> Self { PrMerge { id: row.id, workspace_id: row.workspace_id, repo_id: row.repo_id, target_branch_name: row.target_branch_name, pr_info: PullRequestInfo { number: row.pr_number.expect("pr merge must have pr_number"), url: row.pr_url.expect("pr merge must have pr_url"), status: row.pr_status.expect("pr merge must have status"), merged_at: row.pr_merged_at, merge_commit_sha: row.pr_merge_commit_sha, }, created_at: row.created_at, } } } impl From for Merge { fn from(row: MergeRow) -> Self { match row.merge_type { MergeType::Direct => Merge::Direct(DirectMerge::from(row)), MergeType::Pr => Merge::Pr(PrMerge::from(row)), } } } ================================================ FILE: crates/db/src/models/migration_state.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{Executor, FromRow, Sqlite, SqlitePool, Type}; use strum_macros::{Display, EnumString}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum MigrationStateError { #[error(transparent)] Database(#[from] sqlx::Error), } #[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, EnumString, Display)] #[sqlx(type_name = "text", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum EntityType { Project, Task, PrMerge, Workspace, } #[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, EnumString, Display, Default)] #[sqlx(type_name = "text", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum MigrationStatus { #[default] Pending, Migrated, Failed, Skipped, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct MigrationState { pub id: Uuid, pub entity_type: EntityType, pub local_id: Uuid, pub remote_id: Option, pub status: MigrationStatus, pub error_message: Option, pub attempt_count: i64, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateMigrationState { pub entity_type: EntityType, pub local_id: Uuid, } impl MigrationState { pub async fn find_all(pool: &SqlitePool) -> Result, MigrationStateError> { let records = sqlx::query_as!( MigrationState, r#"SELECT id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM migration_state ORDER BY created_at ASC"# ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_by_entity_type( pool: &SqlitePool, entity_type: EntityType, ) -> Result, MigrationStateError> { let entity_type_str = entity_type.to_string(); let records = sqlx::query_as!( MigrationState, r#"SELECT id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM migration_state WHERE entity_type = $1 ORDER BY created_at ASC"#, entity_type_str ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_by_status( pool: &SqlitePool, status: MigrationStatus, ) -> Result, MigrationStateError> { let status_str = status.to_string(); let records = sqlx::query_as!( MigrationState, r#"SELECT id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM migration_state WHERE status = $1 ORDER BY created_at ASC"#, status_str ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_pending_by_type( pool: &SqlitePool, entity_type: EntityType, ) -> Result, MigrationStateError> { let entity_type_str = entity_type.to_string(); let records = sqlx::query_as!( MigrationState, r#"SELECT id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM migration_state WHERE entity_type = $1 AND status = 'pending' ORDER BY created_at ASC"#, entity_type_str ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_by_entity( pool: &SqlitePool, entity_type: EntityType, local_id: Uuid, ) -> Result, MigrationStateError> { let entity_type_str = entity_type.to_string(); let record = sqlx::query_as!( MigrationState, r#"SELECT id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM migration_state WHERE entity_type = $1 AND local_id = $2"#, entity_type_str, local_id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn get_remote_id( pool: &SqlitePool, entity_type: EntityType, local_id: Uuid, ) -> Result, MigrationStateError> { let entity_type_str = entity_type.to_string(); let record = sqlx::query_scalar!( r#"SELECT remote_id as "remote_id: Uuid" FROM migration_state WHERE entity_type = $1 AND local_id = $2 AND status = 'migrated'"#, entity_type_str, local_id ) .fetch_optional(pool) .await?; Ok(record.flatten()) } pub async fn create<'e, E>( executor: E, data: &CreateMigrationState, ) -> Result where E: Executor<'e, Database = Sqlite>, { let id = Uuid::new_v4(); let entity_type_str = data.entity_type.to_string(); let record = sqlx::query_as!( MigrationState, r#"INSERT INTO migration_state (id, entity_type, local_id) VALUES ($1, $2, $3) RETURNING id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, entity_type_str, data.local_id ) .fetch_one(executor) .await?; Ok(record) } pub async fn upsert<'e, E>( executor: E, data: &CreateMigrationState, ) -> Result where E: Executor<'e, Database = Sqlite>, { let id = Uuid::new_v4(); let entity_type_str = data.entity_type.to_string(); let record = sqlx::query_as!( MigrationState, r#"INSERT INTO migration_state (id, entity_type, local_id) VALUES ($1, $2, $3) ON CONFLICT (entity_type, local_id) DO UPDATE SET updated_at = datetime('now', 'subsec') RETURNING id as "id!: Uuid", entity_type as "entity_type!: EntityType", local_id as "local_id!: Uuid", remote_id as "remote_id: Uuid", status as "status!: MigrationStatus", error_message, attempt_count as "attempt_count!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, entity_type_str, data.local_id ) .fetch_one(executor) .await?; Ok(record) } pub async fn mark_migrated<'e, E>( executor: E, entity_type: EntityType, local_id: Uuid, remote_id: Uuid, ) -> Result<(), MigrationStateError> where E: Executor<'e, Database = Sqlite>, { let entity_type_str = entity_type.to_string(); sqlx::query!( r#"UPDATE migration_state SET status = 'migrated', remote_id = $3, error_message = NULL, updated_at = datetime('now', 'subsec') WHERE entity_type = $1 AND local_id = $2"#, entity_type_str, local_id, remote_id ) .execute(executor) .await?; Ok(()) } pub async fn mark_failed<'e, E>( executor: E, entity_type: EntityType, local_id: Uuid, error_message: &str, ) -> Result<(), MigrationStateError> where E: Executor<'e, Database = Sqlite>, { let entity_type_str = entity_type.to_string(); sqlx::query!( r#"UPDATE migration_state SET status = 'failed', error_message = $3, attempt_count = attempt_count + 1, updated_at = datetime('now', 'subsec') WHERE entity_type = $1 AND local_id = $2"#, entity_type_str, local_id, error_message ) .execute(executor) .await?; Ok(()) } pub async fn mark_skipped<'e, E>( executor: E, entity_type: EntityType, local_id: Uuid, reason: &str, ) -> Result<(), MigrationStateError> where E: Executor<'e, Database = Sqlite>, { let entity_type_str = entity_type.to_string(); sqlx::query!( r#"UPDATE migration_state SET status = 'skipped', error_message = $3, updated_at = datetime('now', 'subsec') WHERE entity_type = $1 AND local_id = $2"#, entity_type_str, local_id, reason ) .execute(executor) .await?; Ok(()) } pub async fn reset_failed(pool: &SqlitePool) -> Result { let result = sqlx::query!( r#"UPDATE migration_state SET status = 'pending', error_message = NULL, updated_at = datetime('now', 'subsec') WHERE status = 'failed'"# ) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn get_stats(pool: &SqlitePool) -> Result { let stats = sqlx::query_as!( MigrationStats, r#"SELECT COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as "pending!: i64", COALESCE(SUM(CASE WHEN status = 'migrated' THEN 1 ELSE 0 END), 0) as "migrated!: i64", COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as "failed!: i64", COALESCE(SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END), 0) as "skipped!: i64", COUNT(*) as "total!: i64" FROM migration_state"# ) .fetch_one(pool) .await?; Ok(stats) } pub async fn clear_all(pool: &SqlitePool) -> Result { let result = sqlx::query!("DELETE FROM migration_state") .execute(pool) .await?; Ok(result.rows_affected()) } } #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct MigrationStats { pub pending: i64, pub migrated: i64, pub failed: i64, pub skipped: i64, pub total: i64, } ================================================ FILE: crates/db/src/models/mod.rs ================================================ pub mod coding_agent_turn; pub mod execution_process; pub mod execution_process_logs; pub mod execution_process_repo_state; pub mod file; pub mod merge; pub mod migration_state; pub mod project; pub mod repo; pub mod requests; pub mod scratch; pub mod session; pub mod tag; pub mod task; pub mod workspace; pub mod workspace_repo; ================================================ FILE: crates/db/src/models/project.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Project { pub id: Uuid, pub name: String, pub default_agent_working_dir: Option, pub remote_project_id: Option, #[ts(type = "Date")] pub created_at: DateTime, #[ts(type = "Date")] pub updated_at: DateTime, } impl Project { pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Project, r#"SELECT id as "id!: Uuid", name, default_agent_working_dir, remote_project_id as "remote_project_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM projects ORDER BY created_at DESC"# ) .fetch_all(pool) .await } pub async fn set_remote_project_id( pool: &SqlitePool, id: Uuid, remote_project_id: Option, ) -> Result<(), sqlx::Error> { sqlx::query!( r#"UPDATE projects SET remote_project_id = $2 WHERE id = $1"#, id, remote_project_id ) .execute(pool) .await?; Ok(()) } } ================================================ FILE: crates/db/src/models/repo.rs ================================================ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::rust::double_option; use sqlx::{Executor, FromRow, Sqlite, SqlitePool}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Serialize, TS)] pub struct SearchResult { pub path: String, pub is_file: bool, pub match_type: SearchMatchType, /// Ranking score based on git history (higher = more recently/frequently edited) #[serde(default)] pub score: i64, } #[derive(Debug, Clone, Serialize, TS)] pub enum SearchMatchType { FileName, DirectoryName, FullPath, } #[derive(Debug, Error)] pub enum RepoError { #[error(transparent)] Database(#[from] sqlx::Error), #[error("Repository not found")] NotFound, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Repo { pub id: Uuid, pub path: PathBuf, pub name: String, pub display_name: String, pub setup_script: Option, pub cleanup_script: Option, pub archive_script: Option, pub copy_files: Option, pub parallel_setup_script: bool, pub dev_server_script: Option, pub default_target_branch: Option, pub default_working_dir: Option, #[ts(type = "Date")] pub created_at: DateTime, #[ts(type = "Date")] pub updated_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct UpdateRepo { #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub display_name: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub setup_script: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub cleanup_script: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub archive_script: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub copy_files: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "boolean | null")] pub parallel_setup_script: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub dev_server_script: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub default_target_branch: Option>, #[serde( default, skip_serializing_if = "Option::is_none", with = "double_option" )] #[ts(optional, type = "string | null")] pub default_working_dir: Option>, } impl Repo { /// Get repos that still have the migration sentinel as their name. /// Used by the startup backfill to fix repo names. pub async fn list_needing_name_fix(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT id as "id!: Uuid", path, name, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script as "parallel_setup_script!: bool", dev_server_script, default_target_branch, default_working_dir, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM repos WHERE name = '__NEEDS_BACKFILL__'"# ) .fetch_all(pool) .await } pub async fn update_name( pool: &SqlitePool, id: Uuid, name: &str, display_name: &str, ) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE repos SET name = $1, display_name = $2, updated_at = datetime('now', 'subsec') WHERE id = $3", name, display_name, id ) .execute(pool) .await?; Ok(()) } pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT id as "id!: Uuid", path, name, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script as "parallel_setup_script!: bool", dev_server_script, default_target_branch, default_working_dir, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM repos WHERE id = $1"#, id ) .fetch_optional(pool) .await } pub async fn find_by_ids(pool: &SqlitePool, ids: &[Uuid]) -> Result, sqlx::Error> { if ids.is_empty() { return Ok(Vec::new()); } // Fetch each repo individually since SQLite doesn't support array parameters let mut repos = Vec::with_capacity(ids.len()); for id in ids { if let Some(repo) = Self::find_by_id(pool, *id).await? { repos.push(repo); } } Ok(repos) } pub async fn find_or_create<'e, E>( executor: E, path: &Path, display_name: &str, ) -> Result where E: Executor<'e, Database = Sqlite>, { let path_str = path.to_string_lossy().to_string(); let id = Uuid::new_v4(); let repo_name = path .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| id.to_string()); // Use INSERT OR IGNORE + SELECT to handle race conditions atomically sqlx::query_as!( Repo, r#"INSERT INTO repos (id, path, name, display_name) VALUES ($1, $2, $3, $4) ON CONFLICT(path) DO UPDATE SET updated_at = updated_at RETURNING id as "id!: Uuid", path, name, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script as "parallel_setup_script!: bool", dev_server_script, default_target_branch, default_working_dir, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, path_str, repo_name, display_name, ) .fetch_one(executor) .await } pub async fn delete_orphaned(pool: &SqlitePool) -> Result { let result = sqlx::query!( r#"DELETE FROM repos WHERE id NOT IN (SELECT repo_id FROM project_repos) AND id NOT IN (SELECT repo_id FROM workspace_repos)"# ) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT id as "id!: Uuid", path, name, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script as "parallel_setup_script!: bool", dev_server_script, default_target_branch, default_working_dir, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM repos ORDER BY display_name ASC"# ) .fetch_all(pool) .await } pub async fn list_by_recent_workspace_usage( pool: &SqlitePool, ) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT r.id as "id!: Uuid", r.path, r.name, r.display_name, r.setup_script, r.cleanup_script, r.archive_script, r.copy_files, r.parallel_setup_script as "parallel_setup_script!: bool", r.dev_server_script, r.default_target_branch, r.default_working_dir, r.created_at as "created_at!: DateTime", r.updated_at as "updated_at!: DateTime" FROM repos r LEFT JOIN ( SELECT repo_id, MAX(updated_at) AS last_used_at FROM workspace_repos GROUP BY repo_id ) wr ON wr.repo_id = r.id ORDER BY wr.last_used_at DESC, r.display_name ASC"# ) .fetch_all(pool) .await } /// Returns the names of active (non-archived) workspaces that reference this repo. pub async fn active_workspace_names( pool: &SqlitePool, repo_id: Uuid, ) -> Result, sqlx::Error> { let rows = sqlx::query_scalar!( r#"SELECT w.name AS "name: String" FROM workspaces w JOIN workspace_repos wr ON wr.workspace_id = w.id WHERE wr.repo_id = $1 AND w.archived = FALSE"#, repo_id ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|name| name.unwrap_or_else(|| "Unnamed workspace".to_string())) .collect()) } /// Delete a repo by ID. Relies on ON DELETE CASCADE for workspace_repos / project_repos. pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { let result = sqlx::query!("DELETE FROM repos WHERE id = $1", id) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn update( pool: &SqlitePool, id: Uuid, payload: &UpdateRepo, ) -> Result { let existing = Self::find_by_id(pool, id) .await? .ok_or(RepoError::NotFound)?; // None = don't update (use existing) // Some(None) = set to NULL // Some(Some(v)) = set to v let display_name = match &payload.display_name { None => existing.display_name, Some(v) => v.clone().unwrap_or_default(), }; let setup_script = match &payload.setup_script { None => existing.setup_script, Some(v) => v.clone(), }; let cleanup_script = match &payload.cleanup_script { None => existing.cleanup_script, Some(v) => v.clone(), }; let archive_script = match &payload.archive_script { None => existing.archive_script, Some(v) => v.clone(), }; let copy_files = match &payload.copy_files { None => existing.copy_files, Some(v) => v.clone(), }; let parallel_setup_script = match &payload.parallel_setup_script { None => existing.parallel_setup_script, Some(v) => v.unwrap_or(false), }; let dev_server_script = match &payload.dev_server_script { None => existing.dev_server_script, Some(v) => v.clone(), }; let default_target_branch = match &payload.default_target_branch { None => existing.default_target_branch, Some(v) => v.clone(), }; let default_working_dir = match &payload.default_working_dir { None => existing.default_working_dir, Some(v) => v.clone(), }; sqlx::query_as!( Repo, r#"UPDATE repos SET display_name = $1, setup_script = $2, cleanup_script = $3, archive_script = $4, copy_files = $5, parallel_setup_script = $6, dev_server_script = $7, default_target_branch = $8, default_working_dir = $9, updated_at = datetime('now', 'subsec') WHERE id = $10 RETURNING id as "id!: Uuid", path, name, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script as "parallel_setup_script!: bool", dev_server_script, default_target_branch, default_working_dir, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, display_name, setup_script, cleanup_script, archive_script, copy_files, parallel_setup_script, dev_server_script, default_target_branch, default_working_dir, id ) .fetch_one(pool) .await .map_err(RepoError::from) } } ================================================ FILE: crates/db/src/models/requests.rs ================================================ use executors::profile::ExecutorConfig; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use super::{execution_process::ExecutionProcess, workspace::Workspace}; #[derive(Debug, Deserialize, Serialize)] pub struct ContainerQuery { #[serde(rename = "ref")] pub container_ref: String, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct WorkspaceRepoInput { pub repo_id: Uuid, pub target_branch: String, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CreateWorkspaceApiRequest { pub name: Option, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct LinkedIssueInfo { pub remote_project_id: Uuid, pub issue_id: Uuid, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CreateAndStartWorkspaceRequest { pub name: Option, pub repos: Vec, pub linked_issue: Option, pub executor_config: ExecutorConfig, pub prompt: String, pub attachment_ids: Option>, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CreateAndStartWorkspaceResponse { pub workspace: Workspace, pub execution_process: ExecutionProcess, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct UpdateWorkspace { pub archived: Option, pub pinned: Option, pub name: Option, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct UpdateSession { pub name: Option, } ================================================ FILE: crates/db/src/models/scratch.rs ================================================ use chrono::{DateTime, Utc}; use executors::profile::ExecutorConfig; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use strum_macros::{Display, EnumDiscriminants, EnumString}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Error)] pub enum ScratchError { #[error(transparent)] Serde(#[from] serde_json::Error), #[error(transparent)] Database(#[from] sqlx::Error), #[error("Scratch type mismatch: expected '{expected}' but got '{actual}'")] TypeMismatch { expected: String, actual: String }, } /// Data for a draft follow-up scratch #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftFollowUpData { pub message: String, #[serde(alias = "executor_profile_id", alias = "config")] pub executor_config: ExecutorConfig, } /// Data for preview settings scratch (URL override and screen size) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PreviewSettingsData { pub url: String, #[serde(default)] pub screen_size: Option, #[serde(default)] pub responsive_width: Option, #[serde(default)] pub responsive_height: Option, } /// Data for workspace notes scratch #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct WorkspaceNotesData { pub content: String, } /// Workspace-specific panel state #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct WorkspacePanelStateData { pub right_main_panel_mode: Option, pub is_left_main_panel_visible: bool, } /// Workspace sidebar PR filter state #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] #[serde(rename_all = "snake_case")] pub enum WorkspacePrFilterData { #[default] All, HasPr, NoPr, } /// Workspace sidebar sort field #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] #[serde(rename_all = "snake_case")] pub enum WorkspaceSortByData { #[default] UpdatedAt, CreatedAt, } /// Workspace sidebar sort order #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] #[serde(rename_all = "snake_case")] pub enum WorkspaceSortOrderData { Asc, #[default] Desc, } /// Workspace sidebar filter state #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] pub struct WorkspaceFilterStateData { #[serde(default)] pub project_ids: Vec, #[serde(default)] pub pr_filter: WorkspacePrFilterData, } /// Workspace sidebar sort state #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] pub struct WorkspaceSortStateData { #[serde(default)] pub sort_by: WorkspaceSortByData, #[serde(default)] pub sort_order: WorkspaceSortOrderData, } /// Data for UI preferences scratch (global preferences stored per-user or per-device) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct UiPreferencesData { /// Preferred repo actions per repo #[serde(default)] pub repo_actions: std::collections::HashMap, /// Expanded/collapsed state for UI sections #[serde(default)] pub expanded: std::collections::HashMap, /// Context bar position #[serde(default)] pub context_bar_position: Option, /// Pane sizes #[serde(default)] pub pane_sizes: std::collections::HashMap, /// Collapsed paths per workspace in file tree #[serde(default)] pub collapsed_paths: std::collections::HashMap>, /// Preferred file-search repo #[serde(default)] pub file_search_repo_id: Option, /// Global left sidebar visibility #[serde(default)] pub is_left_sidebar_visible: Option, /// Global right sidebar visibility #[serde(default)] pub is_right_sidebar_visible: Option, /// Global terminal visibility #[serde(default)] pub is_terminal_visible: Option, /// Workspace-specific panel states #[serde(default)] pub workspace_panel_states: std::collections::HashMap, /// Workspace sidebar filter preferences #[serde(default)] pub workspace_filters: WorkspaceFilterStateData, /// Workspace sidebar sort preferences #[serde(default)] pub workspace_sort: WorkspaceSortStateData, /// Last selected organization ID #[serde(default)] pub selected_org_id: Option, /// Last selected project ID #[serde(default)] pub selected_project_id: Option, /// Default setting for creating a draft workspace from new issues #[serde(default)] pub create_draft_workspace_by_default: Option, } /// Linked issue data for draft workspace scratch #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftWorkspaceLinkedIssue { pub issue_id: String, pub simple_id: String, pub title: String, pub remote_project_id: String, } /// Uploaded attachment stored in a draft workspace #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftWorkspaceAttachment { pub id: Uuid, pub file_path: String, pub original_name: String, #[serde(default)] pub mime_type: Option, pub size_bytes: i64, } /// Data for a draft workspace scratch (new workspace creation) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftWorkspaceData { pub message: String, #[serde(default)] pub repos: Vec, #[serde(default, alias = "selected_profile", alias = "config")] pub executor_config: Option, #[serde(default)] pub linked_issue: Option, #[serde(default)] pub attachments: Vec, } /// Repository entry in a draft workspace #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftWorkspaceRepo { pub repo_id: Uuid, pub target_branch: String, } /// Data for project repo defaults scratch (default repos/branches per project) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ProjectRepoDefaultsData { pub repos: Vec, } /// Data for a draft issue scratch (issue creation on kanban board) #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct DraftIssueData { #[serde(default)] pub title: String, #[serde(default)] pub description: Option, pub status_id: String, /// Stored as the string value of IssuePriority (e.g. "urgent", "high", "medium", "low") #[serde(default)] pub priority: Option, #[serde(default)] pub assignee_ids: Vec, #[serde(default)] pub tag_ids: Vec, #[serde(default)] pub create_draft_workspace: bool, /// The project this draft belongs to pub project_id: String, /// Parent issue ID if creating a sub-issue #[serde(default)] pub parent_issue_id: Option, } /// The payload of a scratch, tagged by type. The type is part of the composite primary key. /// Data is stored as markdown string. #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumDiscriminants)] #[serde(tag = "type", content = "data", rename_all = "SCREAMING_SNAKE_CASE")] #[strum_discriminants(name(ScratchType))] #[strum_discriminants(derive(Display, EnumString, Serialize, Deserialize, TS))] #[strum_discriminants(ts(use_ts_enum))] #[strum_discriminants(serde(rename_all = "SCREAMING_SNAKE_CASE"))] #[strum_discriminants(strum(serialize_all = "SCREAMING_SNAKE_CASE"))] pub enum ScratchPayload { DraftTask(String), DraftFollowUp(DraftFollowUpData), DraftWorkspace(DraftWorkspaceData), DraftIssue(DraftIssueData), PreviewSettings(PreviewSettingsData), WorkspaceNotes(WorkspaceNotesData), UiPreferences(UiPreferencesData), ProjectRepoDefaults(ProjectRepoDefaultsData), } impl ScratchPayload { /// Returns the scratch type for this payload pub fn scratch_type(&self) -> ScratchType { ScratchType::from(self) } /// Validates that the payload type matches the expected type pub fn validate_type(&self, expected: ScratchType) -> Result<(), ScratchError> { let actual = self.scratch_type(); if actual != expected { return Err(ScratchError::TypeMismatch { expected: expected.to_string(), actual: actual.to_string(), }); } Ok(()) } } #[derive(Debug, Clone, FromRow)] struct ScratchRow { pub id: Uuid, pub scratch_type: String, pub payload: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct Scratch { pub id: Uuid, pub payload: ScratchPayload, pub created_at: DateTime, pub updated_at: DateTime, } impl Scratch { /// Returns the scratch type derived from the payload pub fn scratch_type(&self) -> ScratchType { self.payload.scratch_type() } } impl TryFrom for Scratch { type Error = ScratchError; fn try_from(r: ScratchRow) -> Result { let payload: ScratchPayload = serde_json::from_str(&r.payload)?; payload.validate_type(r.scratch_type.parse().map_err(|_| { ScratchError::TypeMismatch { expected: r.scratch_type.clone(), actual: payload.scratch_type().to_string(), } })?)?; Ok(Scratch { id: r.id, payload, created_at: r.created_at, updated_at: r.updated_at, }) } } /// Request body for creating a scratch (id comes from URL path, type from payload) #[derive(Debug, Serialize, Deserialize, TS)] pub struct CreateScratch { pub payload: ScratchPayload, } /// Request body for updating a scratch #[derive(Debug, Serialize, Deserialize, TS)] pub struct UpdateScratch { pub payload: ScratchPayload, } impl Scratch { pub async fn create( pool: &SqlitePool, id: Uuid, data: &CreateScratch, ) -> Result { let scratch_type_str = data.payload.scratch_type().to_string(); let payload_str = serde_json::to_string(&data.payload)?; let row = sqlx::query_as!( ScratchRow, r#" INSERT INTO scratch (id, scratch_type, payload) VALUES ($1, $2, $3) RETURNING id as "id!: Uuid", scratch_type, payload, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" "#, id, scratch_type_str, payload_str, ) .fetch_one(pool) .await?; Scratch::try_from(row) } pub async fn find_by_id( pool: &SqlitePool, id: Uuid, scratch_type: &ScratchType, ) -> Result, ScratchError> { let scratch_type_str = scratch_type.to_string(); let row = sqlx::query_as!( ScratchRow, r#" SELECT id as "id!: Uuid", scratch_type, payload, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM scratch WHERE id = $1 AND scratch_type = $2 "#, id, scratch_type_str, ) .fetch_optional(pool) .await?; let scratch = row.map(Scratch::try_from).transpose()?; Ok(scratch) } pub async fn find_all(pool: &SqlitePool) -> Result, ScratchError> { let rows = sqlx::query_as!( ScratchRow, r#" SELECT id as "id!: Uuid", scratch_type, payload, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM scratch ORDER BY created_at DESC "# ) .fetch_all(pool) .await?; let scratches = rows .into_iter() .filter_map(|row| Scratch::try_from(row).ok()) .collect(); Ok(scratches) } /// Upsert a scratch record - creates if not exists, updates if exists. pub async fn update( pool: &SqlitePool, id: Uuid, scratch_type: &ScratchType, data: &UpdateScratch, ) -> Result { let payload_str = serde_json::to_string(&data.payload)?; let scratch_type_str = scratch_type.to_string(); // Upsert: insert if not exists, update if exists let row = sqlx::query_as!( ScratchRow, r#" INSERT INTO scratch (id, scratch_type, payload) VALUES ($1, $2, $3) ON CONFLICT(id, scratch_type) DO UPDATE SET payload = excluded.payload, updated_at = datetime('now', 'subsec') RETURNING id as "id!: Uuid", scratch_type, payload, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" "#, id, scratch_type_str, payload_str, ) .fetch_one(pool) .await?; Scratch::try_from(row) } pub async fn delete( pool: &SqlitePool, id: Uuid, scratch_type: &ScratchType, ) -> Result { let scratch_type_str = scratch_type.to_string(); let result = sqlx::query!( "DELETE FROM scratch WHERE id = $1 AND scratch_type = $2", id, scratch_type_str ) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn find_by_rowid( pool: &SqlitePool, rowid: i64, ) -> Result, ScratchError> { let row = sqlx::query_as!( ScratchRow, r#" SELECT id as "id!: Uuid", scratch_type, payload, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM scratch WHERE rowid = $1 "#, rowid ) .fetch_optional(pool) .await?; let scratch = row.map(Scratch::try_from).transpose()?; Ok(scratch) } } ================================================ FILE: crates/db/src/models/session.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; use super::workspace_repo::WorkspaceRepo; #[derive(Debug, Error)] pub enum SessionError { #[error(transparent)] Database(#[from] sqlx::Error), #[error("Session not found")] NotFound, #[error("Workspace not found")] WorkspaceNotFound, #[error("Executor mismatch: session uses {expected} but request specified {actual}")] ExecutorMismatch { expected: String, actual: String }, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Session { pub id: Uuid, pub workspace_id: Uuid, pub name: Option, pub executor: Option, pub agent_working_dir: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize, TS)] pub struct CreateSession { pub executor: Option, pub name: Option, } impl Session { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Session, r#"SELECT id AS "id!: Uuid", workspace_id AS "workspace_id!: Uuid", name, executor, agent_working_dir, created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM sessions WHERE id = $1"#, id ) .fetch_optional(pool) .await } /// Find all sessions for a workspace, ordered by most recently used. /// "Most recently used" is defined as the most recent non-dev server execution process. /// Sessions with no executions fall back to created_at for ordering. pub async fn find_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( Session, r#"SELECT s.id AS "id!: Uuid", s.workspace_id AS "workspace_id!: Uuid", s.name, s.executor, s.agent_working_dir, s.created_at AS "created_at!: DateTime", s.updated_at AS "updated_at!: DateTime" FROM sessions s LEFT JOIN ( SELECT ep.session_id, MAX(ep.created_at) as last_used FROM execution_processes ep WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE GROUP BY ep.session_id ) latest_ep ON s.id = latest_ep.session_id WHERE s.workspace_id = $1 ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC"#, workspace_id ) .fetch_all(pool) .await } /// Find the most recently used session for a workspace. /// "Most recently used" is defined as the most recent non-dev server execution process. /// Sessions with no executions fall back to created_at for ordering. pub async fn find_latest_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( Session, r#"SELECT s.id AS "id!: Uuid", s.workspace_id AS "workspace_id!: Uuid", s.name, s.executor, s.agent_working_dir, s.created_at AS "created_at!: DateTime", s.updated_at AS "updated_at!: DateTime" FROM sessions s LEFT JOIN ( SELECT ep.session_id, MAX(ep.created_at) as last_used FROM execution_processes ep WHERE ep.run_reason != 'devserver' AND ep.dropped = FALSE GROUP BY ep.session_id ) latest_ep ON s.id = latest_ep.session_id WHERE s.workspace_id = $1 ORDER BY COALESCE(latest_ep.last_used, s.created_at) DESC LIMIT 1"#, workspace_id ) .fetch_optional(pool) .await } /// Find the first-created session for a workspace. /// This is a temporary policy for orchestrator MCP session discovery. pub async fn find_first_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Session>( r#"SELECT id, workspace_id, name, executor, agent_working_dir, created_at, updated_at FROM sessions WHERE workspace_id = ? ORDER BY created_at ASC, id ASC LIMIT 1"#, ) .bind(workspace_id) .fetch_optional(pool) .await } pub async fn create( pool: &SqlitePool, data: &CreateSession, id: Uuid, workspace_id: Uuid, ) -> Result { let agent_working_dir = Self::resolve_agent_working_dir(pool, workspace_id).await?; let name = data.name.as_deref().filter(|s| !s.is_empty()); Ok(sqlx::query_as!( Session, r#"INSERT INTO sessions (id, workspace_id, name, executor, agent_working_dir) VALUES ($1, $2, $3, $4, $5) RETURNING id AS "id!: Uuid", workspace_id AS "workspace_id!: Uuid", name, executor, agent_working_dir, created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime""#, id, workspace_id, name, data.executor, agent_working_dir ) .fetch_one(pool) .await?) } async fn resolve_agent_working_dir( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace_id).await?; if repos.len() != 1 { return Ok(None); } let repo = &repos[0]; let path = match repo.default_working_dir.as_deref() { Some(subdir) if !subdir.is_empty() => std::path::PathBuf::from(&repo.name).join(subdir), _ => std::path::PathBuf::from(&repo.name), }; Ok(Some(path.to_string_lossy().to_string())) } pub async fn update( pool: &SqlitePool, id: Uuid, name: Option<&str>, ) -> Result<(), sqlx::Error> { let name_value = name.filter(|s| !s.is_empty()); let name_provided = name.is_some(); sqlx::query!( r#"UPDATE sessions SET name = CASE WHEN $1 THEN $2 ELSE name END, updated_at = datetime('now', 'subsec') WHERE id = $3"#, name_provided, name_value, id ) .execute(pool) .await?; Ok(()) } pub async fn update_executor( pool: &SqlitePool, id: Uuid, executor: &str, ) -> Result<(), sqlx::Error> { sqlx::query!( r#"UPDATE sessions SET executor = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2"#, executor, id ) .execute(pool) .await?; Ok(()) } } ================================================ FILE: crates/db/src/models/tag.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Tag { pub id: Uuid, pub tag_name: String, pub content: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize, TS)] pub struct CreateTag { pub tag_name: String, pub content: String, } #[derive(Debug, Deserialize, TS)] pub struct UpdateTag { pub tag_name: Option, pub content: Option, } impl Tag { pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Tag, r#"SELECT id as "id!: Uuid", tag_name, content as "content!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tags ORDER BY tag_name ASC"# ) .fetch_all(pool) .await } pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Tag, r#"SELECT id as "id!: Uuid", tag_name, content as "content!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tags WHERE id = $1"#, id ) .fetch_optional(pool) .await } pub async fn create(pool: &SqlitePool, data: &CreateTag) -> Result { let id = Uuid::new_v4(); sqlx::query_as!( Tag, r#"INSERT INTO tags (id, tag_name, content) VALUES ($1, $2, $3) RETURNING id as "id!: Uuid", tag_name, content as "content!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, data.tag_name, data.content ) .fetch_one(pool) .await } pub async fn update( pool: &SqlitePool, id: Uuid, data: &UpdateTag, ) -> Result { let existing = Self::find_by_id(pool, id) .await? .ok_or(sqlx::Error::RowNotFound)?; let tag_name = data.tag_name.as_ref().unwrap_or(&existing.tag_name); let content = data.content.as_ref().unwrap_or(&existing.content); sqlx::query_as!( Tag, r#"UPDATE tags SET tag_name = $2, content = $3, updated_at = datetime('now', 'subsec') WHERE id = $1 RETURNING id as "id!: Uuid", tag_name, content as "content!", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, tag_name, content ) .fetch_one(pool) .await } pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { let result = sqlx::query!("DELETE FROM tags WHERE id = $1", id) .execute(pool) .await?; Ok(result.rows_affected()) } } ================================================ FILE: crates/db/src/models/task.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool, Type}; use strum_macros::{Display, EnumString}; use ts_rs::TS; use uuid::Uuid; #[derive( Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display, Default, )] #[sqlx(type_name = "task_status", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum TaskStatus { #[default] Todo, InProgress, InReview, Done, Cancelled, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Task { pub id: Uuid, pub project_id: Uuid, // Foreign key to Project pub title: String, pub description: Option, pub status: TaskStatus, pub parent_workspace_id: Option, // Foreign key to parent Workspace pub created_at: DateTime, pub updated_at: DateTime, } impl Task { pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Task, r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks ORDER BY created_at ASC"# ) .fetch_all(pool) .await } pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Task, r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_workspace_id as "parent_workspace_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1"#, id ) .fetch_optional(pool) .await } } ================================================ FILE: crates/db/src/models/workspace.rs ================================================ use chrono::{DateTime, Utc}; use executors::actions::{ExecutorAction, ExecutorActionType}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; /// Maximum length for auto-generated workspace names (derived from first user prompt) const WORKSPACE_NAME_MAX_LEN: usize = 60; use super::{ execution_process::ExecutorActionField, session::Session, workspace_repo::{RepoWithTargetBranch, WorkspaceRepo}, }; #[derive(Debug, Error)] pub enum WorkspaceError { #[error(transparent)] Database(#[from] sqlx::Error), #[error("Workspace not found")] WorkspaceNotFound, #[error("Validation error: {0}")] ValidationError(String), #[error("Branch not found: {0}")] BranchNotFound(String), } #[derive(Debug, Clone, Serialize)] pub struct ContainerInfo { pub workspace_id: Uuid, } #[derive(Debug)] struct WorkspaceContainerRefRow { id: Uuid, container_ref: String, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct Workspace { pub id: Uuid, pub task_id: Option, pub container_ref: Option, pub branch: String, pub setup_completed_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, pub archived: bool, pub pinned: bool, pub name: Option, pub worktree_deleted: bool, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct WorkspaceWithStatus { #[serde(flatten)] #[ts(flatten)] pub workspace: Workspace, pub is_running: bool, pub is_errored: bool, } impl std::ops::Deref for WorkspaceWithStatus { type Target = Workspace; fn deref(&self) -> &Self::Target { &self.workspace } } #[derive(Debug, Deserialize, TS)] pub struct CreateFollowUpAttempt { pub prompt: String, } /// Context data for resume operations (simplified) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttemptResumeContext { pub execution_history: String, pub cumulative_diffs: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceContext { pub workspace: Workspace, pub workspace_repos: Vec, pub orchestrator_session_id: Option, } #[derive(Debug, Deserialize, TS)] pub struct CreateWorkspace { pub branch: String, pub name: Option, } impl Workspace { /// Fetch all workspaces. Newest first. pub async fn fetch_all(pool: &SqlitePool) -> Result, WorkspaceError> { let workspaces = sqlx::query_as!( Workspace, r#"SELECT id AS "id!: Uuid", task_id AS "task_id: Uuid", container_ref, branch, setup_completed_at AS "setup_completed_at: DateTime", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" FROM workspaces ORDER BY created_at DESC"# ) .fetch_all(pool) .await .map_err(WorkspaceError::Database)?; Ok(workspaces) } /// Load full workspace context by workspace ID. pub async fn load_context( pool: &SqlitePool, workspace_id: Uuid, ) -> Result { let workspace = Workspace::find_by_id(pool, workspace_id) .await? .ok_or(WorkspaceError::WorkspaceNotFound)?; let workspace_repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace_id).await?; let orchestrator_session_id = Session::find_first_by_workspace_id(pool, workspace_id) .await? .map(|session| session.id); Ok(WorkspaceContext { workspace, workspace_repos, orchestrator_session_id, }) } /// Update container reference pub async fn update_container_ref( pool: &SqlitePool, workspace_id: Uuid, container_ref: &str, ) -> Result<(), sqlx::Error> { let now = Utc::now(); sqlx::query!( "UPDATE workspaces SET container_ref = $1, updated_at = $2 WHERE id = $3", container_ref, now, workspace_id ) .execute(pool) .await?; Ok(()) } pub async fn mark_worktree_deleted( pool: &SqlitePool, workspace_id: Uuid, ) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE workspaces SET worktree_deleted = TRUE, updated_at = datetime('now') WHERE id = ?", workspace_id ) .execute(pool) .await?; Ok(()) } pub async fn clear_worktree_deleted( pool: &SqlitePool, workspace_id: Uuid, ) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE workspaces SET worktree_deleted = FALSE, updated_at = datetime('now') WHERE id = ?", workspace_id ) .execute(pool) .await?; Ok(()) } /// Update the workspace's updated_at timestamp to prevent cleanup. /// Call this when the workspace is accessed (e.g., opened in editor). pub async fn touch(pool: &SqlitePool, workspace_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE workspaces SET updated_at = datetime('now', 'subsec') WHERE id = ?", workspace_id ) .execute(pool) .await?; Ok(()) } pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Workspace, r#"SELECT id AS "id!: Uuid", task_id AS "task_id: Uuid", container_ref, branch, setup_completed_at AS "setup_completed_at: DateTime", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" FROM workspaces WHERE id = $1"#, id ) .fetch_optional(pool) .await } pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { sqlx::query_as!( Workspace, r#"SELECT id AS "id!: Uuid", task_id AS "task_id: Uuid", container_ref, branch, setup_completed_at AS "setup_completed_at: DateTime", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime", archived AS "archived!: bool", pinned AS "pinned!: bool", name, worktree_deleted AS "worktree_deleted!: bool" FROM workspaces WHERE rowid = $1"#, rowid ) .fetch_optional(pool) .await } pub async fn container_ref_exists( pool: &SqlitePool, container_ref: &str, ) -> Result { let result = sqlx::query!( r#"SELECT EXISTS(SELECT 1 FROM workspaces WHERE container_ref = ?) as "exists!: bool""#, container_ref ) .fetch_one(pool) .await?; Ok(result.exists) } /// Find workspaces that are expired and eligible for cleanup. /// Uses accelerated cleanup (1 hour) for archived workspaces. /// Uses standard cleanup (72 hours) for non-archived workspaces. pub async fn find_expired_for_cleanup( pool: &SqlitePool, ) -> Result, sqlx::Error> { sqlx::query_as!( Workspace, r#" SELECT w.id as "id!: Uuid", w.task_id as "task_id: Uuid", w.container_ref, w.branch as "branch!", w.setup_completed_at as "setup_completed_at: DateTime", w.created_at as "created_at!: DateTime", w.updated_at as "updated_at!: DateTime", w.archived as "archived!: bool", w.pinned as "pinned!: bool", w.name, w.worktree_deleted as "worktree_deleted!: bool" FROM workspaces w LEFT JOIN sessions s ON w.id = s.workspace_id LEFT JOIN execution_processes ep ON s.id = ep.session_id AND ep.completed_at IS NOT NULL WHERE w.container_ref IS NOT NULL AND w.worktree_deleted = FALSE AND w.id NOT IN ( SELECT DISTINCT s2.workspace_id FROM sessions s2 JOIN execution_processes ep2 ON s2.id = ep2.session_id WHERE ep2.completed_at IS NULL ) GROUP BY w.id, w.container_ref, w.updated_at HAVING datetime('now', 'localtime', CASE WHEN w.archived = 1 THEN '-1 hours' ELSE '-72 hours' END ) > datetime( MAX( max( datetime(w.updated_at), datetime(ep.completed_at) ) ) ) ORDER BY MAX( CASE WHEN ep.completed_at IS NOT NULL THEN ep.completed_at ELSE w.updated_at END ) ASC "# ) .fetch_all(pool) .await } pub async fn create( pool: &SqlitePool, data: &CreateWorkspace, id: Uuid, ) -> Result { Ok(sqlx::query_as!( Workspace, r#"INSERT INTO workspaces (id, task_id, container_ref, branch, setup_completed_at, name) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id as "id!: Uuid", task_id as "task_id: Uuid", container_ref, branch, setup_completed_at as "setup_completed_at: DateTime", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime", archived as "archived!: bool", pinned as "pinned!: bool", name, worktree_deleted as "worktree_deleted!: bool""#, id, Option::::None, Option::::None, data.branch, Option::>::None, data.name ) .fetch_one(pool) .await?) } pub async fn update_branch_name( pool: &SqlitePool, workspace_id: Uuid, new_branch_name: &str, ) -> Result<(), WorkspaceError> { sqlx::query!( "UPDATE workspaces SET branch = $1, updated_at = datetime('now') WHERE id = $2", new_branch_name, workspace_id, ) .execute(pool) .await?; Ok(()) } pub async fn resolve_container_ref( pool: &SqlitePool, container_ref: &str, ) -> Result { let result = sqlx::query!( r#"SELECT w.id as "workspace_id!: Uuid" FROM workspaces w WHERE w.container_ref = ?"#, container_ref ) .fetch_optional(pool) .await? .ok_or(sqlx::Error::RowNotFound)?; Ok(ContainerInfo { workspace_id: result.workspace_id, }) } /// Find workspace by path using container-ref path containment. /// Used by clients that may open a repo subfolder rather than the workspace root. pub async fn resolve_container_ref_by_prefix( pool: &SqlitePool, path: &str, ) -> Result { let workspaces = sqlx::query_as!( WorkspaceContainerRefRow, r#"SELECT id as "id!: Uuid", container_ref as "container_ref!" FROM workspaces WHERE container_ref IS NOT NULL"#, ) .fetch_all(pool) .await?; Self::best_matching_container_ref( path, workspaces .iter() .map(|ws| (ws.id, ws.container_ref.as_str())), ) .map(|workspace_id| ContainerInfo { workspace_id }) .ok_or(sqlx::Error::RowNotFound) } fn best_matching_container_ref<'a>( path: &str, candidates: impl Iterator, ) -> Option { let path = std::path::Path::new(path); candidates .filter(|(_, container_ref)| { let container_ref = std::path::Path::new(container_ref); path.starts_with(container_ref) || container_ref.starts_with(path) }) .max_by_key(|(_, container_ref)| { std::path::Path::new(container_ref).components().count() }) .map(|(workspace_id, _)| workspace_id) } pub async fn set_archived( pool: &SqlitePool, workspace_id: Uuid, archived: bool, ) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE workspaces SET archived = $1, updated_at = datetime('now', 'subsec') WHERE id = $2", archived, workspace_id ) .execute(pool) .await?; Ok(()) } /// Update workspace fields. Only non-None values will be updated. /// For `name`, pass `Some("")` to clear the name, `Some("foo")` to set it, or `None` to leave unchanged. pub async fn update( pool: &SqlitePool, workspace_id: Uuid, archived: Option, pinned: Option, name: Option<&str>, ) -> Result<(), sqlx::Error> { // Convert empty string to None for name field (to store as NULL) let name_value = name.filter(|s| !s.is_empty()); let name_provided = name.is_some(); sqlx::query!( r#"UPDATE workspaces SET archived = COALESCE($1, archived), pinned = COALESCE($2, pinned), name = CASE WHEN $3 THEN $4 ELSE name END, updated_at = datetime('now', 'subsec') WHERE id = $5"#, archived, pinned, name_provided, name_value, workspace_id ) .execute(pool) .await?; Ok(()) } pub async fn get_first_user_message( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { let actions = sqlx::query_scalar!( r#"SELECT ep.executor_action as "executor_action!: sqlx::types::Json" FROM sessions s JOIN execution_processes ep ON ep.session_id = s.id WHERE s.workspace_id = $1 ORDER BY s.created_at ASC, ep.created_at ASC"#, workspace_id ) .fetch_all(pool) .await?; for action in actions { if let ExecutorActionField::ExecutorAction(action) = action.0 && let Some(prompt) = Self::extract_first_prompt_from_executor_action(&action) { return Ok(Some(prompt)); } } Ok(None) } fn extract_first_prompt_from_executor_action(action: &ExecutorAction) -> Option { let mut current = Some(action); while let Some(action) = current { match action.typ() { ExecutorActionType::CodingAgentInitialRequest(request) => { return Some(request.prompt.clone()); } ExecutorActionType::CodingAgentFollowUpRequest(request) => { return Some(request.prompt.clone()); } ExecutorActionType::ReviewRequest(request) => { return Some(request.prompt.clone()); } ExecutorActionType::ScriptRequest(_) => { current = action.next_action(); } } } None } pub fn truncate_to_name(prompt: &str, max_len: usize) -> String { let trimmed = prompt.trim(); if trimmed.chars().count() <= max_len { trimmed.to_string() } else { let truncated: String = trimmed.chars().take(max_len).collect(); if let Some(last_space) = truncated.rfind(' ') { format!("{}...", &truncated[..last_space]) } else { format!("{}...", truncated) } } } pub async fn find_all_with_status( pool: &SqlitePool, archived: Option, limit: Option, ) -> Result, sqlx::Error> { // Fetch all workspaces with status (uses cached SQLx query) let records = sqlx::query!( r#"SELECT w.id AS "id!: Uuid", w.task_id AS "task_id: Uuid", w.container_ref, w.branch, w.setup_completed_at AS "setup_completed_at: DateTime", w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", CASE WHEN EXISTS ( SELECT 1 FROM sessions s JOIN execution_processes ep ON ep.session_id = s.id WHERE s.workspace_id = w.id AND ep.status = 'running' AND ep.run_reason IN ('setupscript','cleanupscript','codingagent') LIMIT 1 ) THEN 1 ELSE 0 END AS "is_running!: i64", CASE WHEN ( SELECT ep.status FROM sessions s JOIN execution_processes ep ON ep.session_id = s.id WHERE s.workspace_id = w.id AND ep.run_reason IN ('setupscript','cleanupscript','codingagent') ORDER BY ep.created_at DESC LIMIT 1 ) IN ('failed','killed') THEN 1 ELSE 0 END AS "is_errored!: i64" FROM workspaces w ORDER BY w.updated_at DESC"# ) .fetch_all(pool) .await?; let mut workspaces: Vec = records .into_iter() .map(|rec| WorkspaceWithStatus { workspace: Workspace { id: rec.id, task_id: rec.task_id, container_ref: rec.container_ref, branch: rec.branch, setup_completed_at: rec.setup_completed_at, created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, }, is_running: rec.is_running != 0, is_errored: rec.is_errored != 0, }) // Apply archived filter if provided .filter(|ws| archived.is_none_or(|a| ws.workspace.archived == a)) .collect(); // Apply limit if provided (already sorted by updated_at DESC from query) if let Some(lim) = limit { workspaces.truncate(lim as usize); } for ws in &mut workspaces { if ws.workspace.name.is_none() && let Some(prompt) = Self::get_first_user_message(pool, ws.workspace.id).await? { let name = Self::truncate_to_name(&prompt, WORKSPACE_NAME_MAX_LEN); Self::update(pool, ws.workspace.id, None, None, Some(&name)).await?; ws.workspace.name = Some(name); } } Ok(workspaces) } /// Delete a workspace by ID pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { let result = sqlx::query!("DELETE FROM workspaces WHERE id = $1", id) .execute(pool) .await?; Ok(result.rows_affected()) } /// Count total workspaces across all projects pub async fn count_all(pool: &SqlitePool) -> Result { sqlx::query_scalar!(r#"SELECT COUNT(*) as "count!: i64" FROM workspaces"#) .fetch_one(pool) .await .map_err(WorkspaceError::Database) } pub async fn find_by_id_with_status( pool: &SqlitePool, id: Uuid, ) -> Result, sqlx::Error> { let rec = sqlx::query!( r#"SELECT w.id AS "id!: Uuid", w.task_id AS "task_id: Uuid", w.container_ref, w.branch, w.setup_completed_at AS "setup_completed_at: DateTime", w.created_at AS "created_at!: DateTime", w.updated_at AS "updated_at!: DateTime", w.archived AS "archived!: bool", w.pinned AS "pinned!: bool", w.name, w.worktree_deleted AS "worktree_deleted!: bool", CASE WHEN EXISTS ( SELECT 1 FROM sessions s JOIN execution_processes ep ON ep.session_id = s.id WHERE s.workspace_id = w.id AND ep.status = 'running' AND ep.run_reason IN ('setupscript','cleanupscript','codingagent') LIMIT 1 ) THEN 1 ELSE 0 END AS "is_running!: i64", CASE WHEN ( SELECT ep.status FROM sessions s JOIN execution_processes ep ON ep.session_id = s.id WHERE s.workspace_id = w.id AND ep.run_reason IN ('setupscript','cleanupscript','codingagent') ORDER BY ep.created_at DESC LIMIT 1 ) IN ('failed','killed') THEN 1 ELSE 0 END AS "is_errored!: i64" FROM workspaces w WHERE w.id = $1"#, id ) .fetch_optional(pool) .await?; let Some(rec) = rec else { return Ok(None); }; let mut ws = WorkspaceWithStatus { workspace: Workspace { id: rec.id, task_id: rec.task_id, container_ref: rec.container_ref, branch: rec.branch, setup_completed_at: rec.setup_completed_at, created_at: rec.created_at, updated_at: rec.updated_at, archived: rec.archived, pinned: rec.pinned, name: rec.name, worktree_deleted: rec.worktree_deleted, }, is_running: rec.is_running != 0, is_errored: rec.is_errored != 0, }; if ws.workspace.name.is_none() && let Some(prompt) = Self::get_first_user_message(pool, ws.workspace.id).await? { let name = Self::truncate_to_name(&prompt, WORKSPACE_NAME_MAX_LEN); Self::update(pool, ws.workspace.id, None, None, Some(&name)).await?; ws.workspace.name = Some(name); } Ok(Some(ws)) } } #[cfg(test)] mod tests { use uuid::Uuid; use super::Workspace; #[test] fn best_matching_container_ref_prefers_deepest_match() { let broad_id = Uuid::new_v4(); let exact_id = Uuid::new_v4(); let selected = Workspace::best_matching_container_ref( "/tmp/ws/repo/packages/app", [(broad_id, "/tmp"), (exact_id, "/tmp/ws")].into_iter(), ); assert_eq!(selected, Some(exact_id)); } #[test] fn best_matching_container_ref_supports_parent_request_path() { let workspace_id = Uuid::new_v4(); let selected = Workspace::best_matching_container_ref( "/tmp/ws/repo", [(workspace_id, "/tmp/ws/repo/packages/app")].into_iter(), ); assert_eq!(selected, Some(workspace_id)); } #[test] fn best_matching_container_ref_ignores_unrelated_paths() { let workspace_id = Uuid::new_v4(); let selected = Workspace::best_matching_container_ref( "/tmp/other/path", [(workspace_id, "/tmp/ws")].into_iter(), ); assert_eq!(selected, None); } } ================================================ FILE: crates/db/src/models/workspace_repo.rs ================================================ use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use ts_rs::TS; use uuid::Uuid; use super::repo::Repo; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct WorkspaceRepo { pub id: Uuid, pub workspace_id: Uuid, pub repo_id: Uuid, pub target_branch: String, #[ts(type = "Date")] pub created_at: DateTime, #[ts(type = "Date")] pub updated_at: DateTime, } #[derive(Debug, Clone, Deserialize, TS)] pub struct CreateWorkspaceRepo { pub repo_id: Uuid, pub target_branch: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct RepoWithTargetBranch { #[serde(flatten)] pub repo: Repo, pub target_branch: String, } /// Repo info with copy_files configuration. #[derive(Debug, Clone)] pub struct RepoWithCopyFiles { pub id: Uuid, pub path: PathBuf, pub name: String, pub copy_files: Option, } impl WorkspaceRepo { pub async fn create_many( pool: &SqlitePool, workspace_id: Uuid, repos: &[CreateWorkspaceRepo], ) -> Result, sqlx::Error> { if repos.is_empty() { return Ok(Vec::new()); } // Build bulk insert query with VALUES for each repo // SQLite doesn't have great support for bulk inserts with RETURNING, // so we'll use a transaction to batch the inserts efficiently let mut tx = pool.begin().await?; let mut results = Vec::with_capacity(repos.len()); for repo in repos { let id = Uuid::new_v4(); let workspace_repo = sqlx::query_as!( WorkspaceRepo, r#"INSERT INTO workspace_repos (id, workspace_id, repo_id, target_branch) VALUES ($1, $2, $3, $4) RETURNING id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", target_branch, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, workspace_id, repo.repo_id, repo.target_branch ) .fetch_one(&mut *tx) .await?; results.push(workspace_repo); } tx.commit().await?; Ok(results) } pub async fn find_by_workspace_id( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( WorkspaceRepo, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", target_branch, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM workspace_repos WHERE workspace_id = $1"#, workspace_id ) .fetch_all(pool) .await } pub async fn find_repos_for_workspace( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT r.id as "id!: Uuid", r.path, r.name, r.display_name, r.setup_script, r.cleanup_script, r.archive_script, r.copy_files, r.parallel_setup_script as "parallel_setup_script!: bool", r.dev_server_script, r.default_target_branch, r.default_working_dir, r.created_at as "created_at!: DateTime", r.updated_at as "updated_at!: DateTime" FROM repos r JOIN workspace_repos wr ON r.id = wr.repo_id WHERE wr.workspace_id = $1 ORDER BY r.display_name ASC"#, workspace_id ) .fetch_all(pool) .await } pub async fn find_repos_with_target_branch_for_workspace( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { let rows = sqlx::query!( r#"SELECT r.id as "id!: Uuid", r.path, r.name, r.display_name, r.setup_script, r.cleanup_script, r.archive_script, r.copy_files, r.parallel_setup_script as "parallel_setup_script!: bool", r.dev_server_script, r.default_target_branch, r.default_working_dir, r.created_at as "created_at!: DateTime", r.updated_at as "updated_at!: DateTime", wr.target_branch FROM repos r JOIN workspace_repos wr ON r.id = wr.repo_id WHERE wr.workspace_id = $1 ORDER BY r.display_name ASC"#, workspace_id ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| RepoWithTargetBranch { repo: Repo { id: row.id, path: PathBuf::from(row.path), name: row.name, display_name: row.display_name, setup_script: row.setup_script, cleanup_script: row.cleanup_script, archive_script: row.archive_script, copy_files: row.copy_files, parallel_setup_script: row.parallel_setup_script, dev_server_script: row.dev_server_script, default_target_branch: row.default_target_branch, default_working_dir: row.default_working_dir, created_at: row.created_at, updated_at: row.updated_at, }, target_branch: row.target_branch, }) .collect()) } pub async fn find_by_workspace_and_repo_id( pool: &SqlitePool, workspace_id: Uuid, repo_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( WorkspaceRepo, r#"SELECT id as "id!: Uuid", workspace_id as "workspace_id!: Uuid", repo_id as "repo_id!: Uuid", target_branch, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM workspace_repos WHERE workspace_id = $1 AND repo_id = $2"#, workspace_id, repo_id ) .fetch_optional(pool) .await } pub async fn update_target_branch( pool: &SqlitePool, workspace_id: Uuid, repo_id: Uuid, new_target_branch: &str, ) -> Result<(), sqlx::Error> { sqlx::query!( "UPDATE workspace_repos SET target_branch = $1, updated_at = datetime('now') WHERE workspace_id = $2 AND repo_id = $3", new_target_branch, workspace_id, repo_id ) .execute(pool) .await?; Ok(()) } pub async fn update_target_branch_for_children_of_workspace( pool: &SqlitePool, parent_workspace_id: Uuid, old_branch: &str, new_branch: &str, ) -> Result { let result = sqlx::query!( r#"UPDATE workspace_repos SET target_branch = $1, updated_at = datetime('now') WHERE target_branch = $2 AND workspace_id IN ( SELECT w.id FROM workspaces w JOIN tasks t ON w.task_id = t.id WHERE t.parent_workspace_id = $3 )"#, new_branch, old_branch, parent_workspace_id ) .execute(pool) .await?; Ok(result.rows_affected()) } pub async fn find_unique_repos_for_task( pool: &SqlitePool, task_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( Repo, r#"SELECT DISTINCT r.id as "id!: Uuid", r.path, r.name, r.display_name, r.setup_script, r.cleanup_script, r.archive_script, r.copy_files, r.parallel_setup_script as "parallel_setup_script!: bool", r.dev_server_script, r.default_target_branch, r.default_working_dir, r.created_at as "created_at!: DateTime", r.updated_at as "updated_at!: DateTime" FROM repos r JOIN workspace_repos wr ON r.id = wr.repo_id JOIN workspaces w ON wr.workspace_id = w.id WHERE w.task_id = $1 ORDER BY r.display_name ASC"#, task_id ) .fetch_all(pool) .await } /// Find repos for a workspace with their copy_files configuration. pub async fn find_repos_with_copy_files( pool: &SqlitePool, workspace_id: Uuid, ) -> Result, sqlx::Error> { let rows = sqlx::query!( r#"SELECT r.id as "id!: Uuid", r.path, r.name, r.copy_files FROM repos r JOIN workspace_repos wr ON r.id = wr.repo_id WHERE wr.workspace_id = $1"#, workspace_id ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|row| RepoWithCopyFiles { id: row.id, path: PathBuf::from(row.path), name: row.name, copy_files: row.copy_files, }) .collect()) } } ================================================ FILE: crates/deployment/Cargo.toml ================================================ [package] name = "deployment" version = "0.1.33" edition = "2024" [dependencies] db = { path = "../db" } utils = { path = "../utils" } git = { path = "../git" } services = { path = "../services" } worktree-manager = { path = "../worktree-manager" } executors = { path = "../executors" } async-trait = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } relay-control = { path = "../relay-control" } trusted-key-auth = { path = "../trusted-key-auth" } server-info = { path = "../server-info" } tokio = { workspace = true } sqlx = "0.8.6" serde_json = { workspace = true } git2 = { workspace = true } futures = "0.3.31" axum = { workspace = true } ================================================ FILE: crates/deployment/src/lib.rs ================================================ use std::sync::Arc; use anyhow::Error as AnyhowError; use async_trait::async_trait; use axum::response::sse::Event; use db::{DBService, models::workspace::WorkspaceError}; use executors::executors::ExecutorError; use futures::{StreamExt, TryStreamExt}; use git::{GitService, GitServiceError}; use git2::Error as Git2Error; use relay_control::{RelayControl, signing::RelaySigningService}; use serde_json::Value; use server_info::ServerInfo; use services::services::{ analytics::AnalyticsService, approvals::Approvals, auth::AuthContext, config::{Config, ConfigError}, container::{ContainerError, ContainerService}, events::{EventError, EventService}, file::{FileError, FileService}, file_search::FileSearchCache, filesystem::{FilesystemError, FilesystemService}, filesystem_watcher::FilesystemWatcherError, queued_message::QueuedMessageService, remote_client::RemoteClient, repo::RepoService, }; use sqlx::Error as SqlxError; use thiserror::Error; use tokio::sync::RwLock; use trusted_key_auth::runtime::TrustedKeyAuthRuntime; use utils::sentry as sentry_utils; use worktree_manager::WorktreeError; #[derive(Debug, Clone, Copy, Error)] #[error("Remote client not configured")] pub struct RemoteClientNotConfigured; #[derive(Debug, Error)] pub enum DeploymentError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Sqlx(#[from] SqlxError), #[error(transparent)] Git2(#[from] Git2Error), #[error(transparent)] GitServiceError(#[from] GitServiceError), #[error(transparent)] FilesystemWatcherError(#[from] FilesystemWatcherError), #[error(transparent)] Workspace(#[from] WorkspaceError), #[error(transparent)] Container(#[from] ContainerError), #[error(transparent)] Executor(#[from] ExecutorError), #[error(transparent)] File(#[from] FileError), #[error(transparent)] Filesystem(#[from] FilesystemError), #[error(transparent)] Worktree(#[from] WorktreeError), #[error(transparent)] Event(#[from] EventError), #[error(transparent)] Config(#[from] ConfigError), #[error("Remote client not configured")] RemoteClientNotConfigured, #[error(transparent)] Other(#[from] AnyhowError), } #[async_trait] pub trait Deployment: Clone + Send + Sync + 'static { async fn new() -> Result; fn user_id(&self) -> &str; fn config(&self) -> &Arc>; fn db(&self) -> &DBService; fn analytics(&self) -> &Option; fn container(&self) -> &impl ContainerService; fn git(&self) -> &GitService; fn repo(&self) -> &RepoService; fn file(&self) -> &FileService; fn filesystem(&self) -> &FilesystemService; fn events(&self) -> &EventService; fn file_search_cache(&self) -> &Arc; fn approvals(&self) -> &Approvals; fn queued_message_service(&self) -> &QueuedMessageService; fn auth_context(&self) -> &AuthContext; fn relay_control(&self) -> &Arc; fn relay_signing(&self) -> &RelaySigningService; fn server_info(&self) -> &Arc; fn trusted_key_auth(&self) -> &TrustedKeyAuthRuntime; fn remote_client(&self) -> Result { Err(RemoteClientNotConfigured) } fn shared_api_base(&self) -> Option { None } async fn update_sentry_scope(&self) -> Result<(), DeploymentError> { let user_id = self.user_id(); let config = self.config().read().await; let username = config.github.username.as_deref(); let email = config.github.primary_email.as_deref(); sentry_utils::configure_user_scope(user_id, username, email); Ok(()) } async fn track_if_analytics_allowed(&self, event_name: &str, properties: Value) { let analytics_enabled = self.config().read().await.analytics_enabled; // Track events unless user has explicitly opted out if analytics_enabled && let Some(analytics) = self.analytics() { analytics.track_event(self.user_id(), event_name, Some(properties.clone())); } } async fn stream_events( &self, ) -> futures::stream::BoxStream<'static, Result> { self.events() .msg_store() .history_plus_stream() .map_ok(|m| m.to_sse_event()) .boxed() } } ================================================ FILE: crates/executors/Cargo.toml ================================================ [package] name = "executors" version = "0.1.33" edition = "2024" [dependencies] workspace_utils = { path = "../utils", package = "utils" } git = { path = "../git" } tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io", "compat", "rt"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tracing = { workspace = true } toml = "0.8" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } schemars = { workspace = true } dirs = "5.0" xdg = "3.0" async-trait = { workspace = true } command-group = { version = "5.0", features = ["with-tokio"] } regex = "1.11.1" json-patch = "2.0" thiserror = { workspace = true } enum_dispatch = "0.3.13" futures-io = "0.3.31" tokio-stream = { version = "0.1.17", features = ["io-util"] } futures = "0.3.31" bon = "3.6" os_pipe = "1.2" strip-ansi-escapes = "0.2.1" strum = "0.27.2" strum_macros = "0.27.2" convert_case = "0.6" sqlx = "0.8.6" shlex = "1.3.0" agent-client-protocol = { version = "0.8", features = ["unstable"] } codex-protocol = { git = "https://github.com/openai/codex.git", package = "codex-protocol", tag = "rust-v0.114.0" } codex-app-server-protocol = { git = "https://github.com/openai/codex.git", package = "codex-app-server-protocol", tag = "rust-v0.114.0" } sha2 = "0.10" derivative = "2.2.0" reqwest = { workspace = true } eventsource-stream = "0.2" walkdir = "2" rand = "0.8" base64 = "0.22" jsonc-parser = { version = "0.29", features = ["cst", "serde"] } lru = "0.12" async-stream = "0.3" [target.'cfg(windows)'.dependencies] winsplit = "0.1.0" [features] default = [] qa-mode = [] ================================================ FILE: crates/executors/default_mcp.json ================================================ { "vibe_kanban": { "command": "npx", "args": [ "-y", "vibe-kanban@latest", "--mcp" ] }, "context7": { "type": "http", "url": "https://mcp.context7.com/mcp", "headers": { "CONTEXT7_API_KEY": "YOUR_API_KEY" } }, "playwright": { "command": "npx", "args": [ "@playwright/mcp@latest" ] }, "exa": { "command": "npx", "args": [ "-y", "exa-mcp-server", "tools=web_search_exa,get_code_context_exa" ], "env": { "EXA_API_KEY": "YOUR_API_KEY" } }, "chrome_devtools": { "command": "npx", "args": [ "chrome-devtools-mcp@latest" ] }, "dev_manager": { "command": "npx", "args": [ "dev-manager-mcp", "stdio" ] }, "meta": { "vibe_kanban": { "name": "Vibe Kanban", "description": "Create, update and delete Vibe Kanban tasks", "url": "https://www.vibekanban.com/docs/integrations/vibe-kanban-mcp-server", "icon": "favicon-vk-light.svg" }, "context7": { "name": "Context7", "description": "Fetch up-to-date documentation and code examples", "url": "https://github.com/upstash/context7", "icon": "mcp/context7logo.png" }, "playwright": { "name": "Playwright", "description": "Browser automation with Playwright", "url": "https://github.com/microsoft/playwright-mcp", "icon": "mcp/playwright_logo_icon.svg" }, "exa": { "name": "Exa", "description": "Web search and code context retrieval powered by Exa AI", "url": "https://docs.exa.ai/reference/exa-mcp", "icon": "mcp/exa_logo.svg" }, "chrome_devtools": { "name": "Chrome DevTools", "description": "Browser automation, debugging and performance analysis with Chrome DevTools", "url": "https://github.com/ChromeDevTools/chrome-devtools-mcp", "icon": "mcp/chrome_devtools_logo.svg" }, "dev_manager": { "name": "Dev Manager", "description": "Launch and manage multiple dev servers in parallel with automatic port allocation", "url": "https://github.com/BloopAI/dev-manager-mcp", "icon": "mcp/dev_manager_logo.svg" } } } ================================================ FILE: crates/executors/default_profiles.json ================================================ { "executors": { "CLAUDE_CODE": { "DEFAULT": { "CLAUDE_CODE": { "dangerously_skip_permissions": true } } }, "AMP": { "DEFAULT": { "AMP": { "dangerously_allow_all": true } } }, "GEMINI": { "DEFAULT": { "GEMINI": { "yolo": true } } }, "CODEX": { "DEFAULT": { "CODEX": { "sandbox": "danger-full-access" } } }, "OPENCODE": { "DEFAULT": { "OPENCODE": { "auto_approve": true } } }, "QWEN_CODE": { "DEFAULT": { "QWEN_CODE": { "yolo": true } } }, "CURSOR_AGENT": { "DEFAULT": { "CURSOR_AGENT": { "force": true, "model": "auto" } } }, "COPILOT": { "DEFAULT": { "COPILOT": { "allow_all_tools": true } } }, "DROID": { "DEFAULT": { "DROID": { "autonomy": "skip-permissions-unsafe" } } } } } ================================================ FILE: crates/executors/src/actions/coding_agent_follow_up.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use ts_rs::TS; #[cfg(not(feature = "qa-mode"))] use crate::profile::ExecutorConfigs; use crate::{ actions::Executable, approvals::ExecutorApprovalService, env::ExecutionEnv, executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor}, profile::ExecutorConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct CodingAgentFollowUpRequest { pub prompt: String, pub session_id: String, #[serde(default)] pub reset_to_message_id: Option, /// Unified executor identity + overrides #[serde(alias = "executor_profile_id", alias = "profile_variant_label")] pub executor_config: ExecutorConfig, /// Optional relative path to execute the agent in (relative to container_ref). /// If None, uses the container_ref directory directly. #[serde(default)] pub working_dir: Option, } impl CodingAgentFollowUpRequest { pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf { match &self.working_dir { Some(rel_path) => current_dir.join(rel_path), None => current_dir.to_path_buf(), } } pub fn base_executor(&self) -> BaseCodingAgent { self.executor_config.executor } } #[async_trait] impl Executable for CodingAgentFollowUpRequest { #[cfg_attr(feature = "qa-mode", allow(unused_variables))] async fn spawn( &self, current_dir: &Path, approvals: Arc, env: &ExecutionEnv, ) -> Result { let effective_dir = self.effective_dir(current_dir); #[cfg(feature = "qa-mode")] { tracing::info!("QA mode: using mock executor for follow-up instead of real agent"); let executor = crate::executors::qa_mock::QaMockExecutor; return executor .spawn_follow_up( &effective_dir, &self.prompt, &self.session_id, self.reset_to_message_id.as_deref(), env, ) .await; } #[cfg(not(feature = "qa-mode"))] { let profile_id = self.executor_config.profile_id(); let mut agent = ExecutorConfigs::get_cached() .get_coding_agent(&profile_id) .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?; if self.executor_config.has_overrides() { agent.apply_overrides(&self.executor_config); } agent.use_approvals(approvals.clone()); agent .spawn_follow_up( &effective_dir, &self.prompt, &self.session_id, self.reset_to_message_id.as_deref(), env, ) .await } } } ================================================ FILE: crates/executors/src/actions/coding_agent_initial.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use ts_rs::TS; #[cfg(not(feature = "qa-mode"))] use crate::profile::ExecutorConfigs; use crate::{ actions::Executable, approvals::ExecutorApprovalService, env::ExecutionEnv, executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor}, profile::ExecutorConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct CodingAgentInitialRequest { pub prompt: String, /// Unified executor identity + overrides #[serde(alias = "executor_profile_id", alias = "profile_variant_label")] pub executor_config: ExecutorConfig, /// Optional relative path to execute the agent in (relative to container_ref). /// If None, uses the container_ref directory directly. #[serde(default)] pub working_dir: Option, } impl CodingAgentInitialRequest { pub fn base_executor(&self) -> BaseCodingAgent { self.executor_config.executor } pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf { match &self.working_dir { Some(rel_path) => current_dir.join(rel_path), None => current_dir.to_path_buf(), } } } #[async_trait] impl Executable for CodingAgentInitialRequest { #[cfg_attr(feature = "qa-mode", allow(unused_variables))] async fn spawn( &self, current_dir: &Path, approvals: Arc, env: &ExecutionEnv, ) -> Result { let effective_dir = self.effective_dir(current_dir); #[cfg(feature = "qa-mode")] { tracing::info!("QA mode: using mock executor instead of real agent"); let executor = crate::executors::qa_mock::QaMockExecutor; return executor.spawn(&effective_dir, &self.prompt, env).await; } #[cfg(not(feature = "qa-mode"))] { let profile_id = self.executor_config.profile_id(); let mut agent = ExecutorConfigs::get_cached() .get_coding_agent(&profile_id) .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?; if self.executor_config.has_overrides() { agent.apply_overrides(&self.executor_config); } agent.use_approvals(approvals.clone()); agent.spawn(&effective_dir, &self.prompt, env).await } } } ================================================ FILE: crates/executors/src/actions/mod.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use enum_dispatch::enum_dispatch; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::{ actions::{ coding_agent_follow_up::CodingAgentFollowUpRequest, coding_agent_initial::CodingAgentInitialRequest, review::ReviewRequest, script::ScriptRequest, }, approvals::ExecutorApprovalService, env::ExecutionEnv, executors::{BaseCodingAgent, ExecutorError, SpawnedChild}, }; pub mod coding_agent_follow_up; pub mod coding_agent_initial; pub mod review; pub mod script; pub use review::RepoReviewContext; #[enum_dispatch] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type")] pub enum ExecutorActionType { CodingAgentInitialRequest, CodingAgentFollowUpRequest, ScriptRequest, ReviewRequest, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ExecutorAction { pub typ: ExecutorActionType, pub next_action: Option>, } impl ExecutorAction { pub fn new(typ: ExecutorActionType, next_action: Option>) -> Self { Self { typ, next_action } } pub fn append_action(mut self, action: ExecutorAction) -> Self { if let Some(next) = self.next_action { self.next_action = Some(Box::new(next.append_action(action))); } else { self.next_action = Some(Box::new(action)); } self } pub fn typ(&self) -> &ExecutorActionType { &self.typ } pub fn next_action(&self) -> Option<&ExecutorAction> { self.next_action.as_deref() } pub fn base_executor(&self) -> Option { match self.typ() { ExecutorActionType::CodingAgentInitialRequest(request) => Some(request.base_executor()), ExecutorActionType::CodingAgentFollowUpRequest(request) => { Some(request.base_executor()) } ExecutorActionType::ReviewRequest(request) => Some(request.base_executor()), ExecutorActionType::ScriptRequest(_) => None, } } } #[async_trait] #[enum_dispatch(ExecutorActionType)] pub trait Executable { async fn spawn( &self, current_dir: &Path, approvals: Arc, env: &ExecutionEnv, ) -> Result; } #[async_trait] impl Executable for ExecutorAction { async fn spawn( &self, current_dir: &Path, approvals: Arc, env: &ExecutionEnv, ) -> Result { self.typ.spawn(current_dir, approvals, env).await } } ================================================ FILE: crates/executors/src/actions/review.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use crate::{ actions::Executable, approvals::ExecutorApprovalService, env::ExecutionEnv, executors::{BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor}, profile::{ExecutorConfig, ExecutorConfigs}, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct RepoReviewContext { pub repo_id: Uuid, pub repo_name: String, pub base_commit: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ReviewRequest { /// Unified executor identity + overrides #[serde(alias = "executor_profile_id", alias = "profile_variant_label")] pub executor_config: ExecutorConfig, pub context: Option>, pub prompt: String, /// Optional session ID to resume an existing session #[serde(default)] pub session_id: Option, /// Optional relative path to execute the agent in (relative to container_ref). #[serde(default)] pub working_dir: Option, } impl ReviewRequest { pub fn base_executor(&self) -> BaseCodingAgent { self.executor_config.executor } pub fn effective_dir(&self, current_dir: &Path) -> std::path::PathBuf { match &self.working_dir { Some(rel_path) => current_dir.join(rel_path), None => current_dir.to_path_buf(), } } } #[async_trait] impl Executable for ReviewRequest { async fn spawn( &self, current_dir: &Path, approvals: Arc, env: &ExecutionEnv, ) -> Result { let effective_dir = self.effective_dir(current_dir); let profile_id = self.executor_config.profile_id(); let mut agent = ExecutorConfigs::get_cached() .get_coding_agent(&profile_id) .ok_or(ExecutorError::UnknownExecutorType(profile_id.to_string()))?; if self.executor_config.has_overrides() { agent.apply_overrides(&self.executor_config); } agent.use_approvals(approvals.clone()); agent .spawn_review( &effective_dir, &self.prompt, self.session_id.as_deref(), env, ) .await } } ================================================ FILE: crates/executors/src/actions/script.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::process::Command; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, shell::get_shell_command}; use crate::{ actions::Executable, approvals::ExecutorApprovalService, env::ExecutionEnv, executors::{ExecutorError, SpawnedChild}, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub enum ScriptRequestLanguage { Bash, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub enum ScriptContext { SetupScript, CleanupScript, ArchiveScript, DevServer, ToolInstallScript, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ScriptRequest { pub script: String, pub language: ScriptRequestLanguage, pub context: ScriptContext, /// Optional relative path to execute the script in (relative to container_ref). /// If None, uses the container_ref directory directly. #[serde(default)] pub working_dir: Option, } #[async_trait] impl Executable for ScriptRequest { async fn spawn( &self, current_dir: &Path, _approvals: Arc, env: &ExecutionEnv, ) -> Result { // Use working_dir if specified, otherwise use current_dir let effective_dir = match &self.working_dir { Some(rel_path) => current_dir.join(rel_path), None => current_dir.to_path_buf(), }; let (shell_cmd, shell_arg) = get_shell_command(); let mut command = Command::new(shell_cmd); command .kill_on_drop(true) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .arg(shell_arg) .arg(&self.script) .current_dir(&effective_dir); // Apply environment variables env.apply_to_command(&mut command); let child = command.group_spawn_no_window()?; Ok(child.into()) } } ================================================ FILE: crates/executors/src/approvals.rs ================================================ use std::fmt; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio_util::sync::CancellationToken; use workspace_utils::approvals::{ApprovalStatus, QuestionStatus}; /// Errors emitted by executor approval services. #[derive(Debug, Error)] pub enum ExecutorApprovalError { #[error("executor approval session not registered")] SessionNotRegistered, #[error("executor approval request failed: {0}")] RequestFailed(String), #[error("executor approval service unavailable")] ServiceUnavailable, #[error("executor approval request cancelled")] Cancelled, } impl ExecutorApprovalError { pub fn request_failed(err: E) -> Self { Self::RequestFailed(err.to_string()) } } /// Abstraction for executor approval backends. #[async_trait] pub trait ExecutorApprovalService: Send + Sync { /// Creates a tool approval request. Returns the approval_id immediately. async fn create_tool_approval(&self, tool_name: &str) -> Result; /// Creates a question approval request. Returns the approval_id immediately. async fn create_question_approval( &self, tool_name: &str, question_count: usize, ) -> Result; /// Waits for a tool approval to be resolved. Blocks until approved/denied/timed out. async fn wait_tool_approval( &self, approval_id: &str, cancel: CancellationToken, ) -> Result; /// Waits for a question to be answered. Blocks until answered/timed out. async fn wait_question_answer( &self, approval_id: &str, cancel: CancellationToken, ) -> Result; } #[derive(Debug, Default)] pub struct NoopExecutorApprovalService; #[async_trait] impl ExecutorApprovalService for NoopExecutorApprovalService { async fn create_tool_approval( &self, _tool_name: &str, ) -> Result { Ok("noop".to_string()) } async fn create_question_approval( &self, _tool_name: &str, _question_count: usize, ) -> Result { Ok("noop".to_string()) } async fn wait_tool_approval( &self, _approval_id: &str, _cancel: CancellationToken, ) -> Result { Ok(ApprovalStatus::Approved) } async fn wait_question_answer( &self, _approval_id: &str, _cancel: CancellationToken, ) -> Result { Err(ExecutorApprovalError::ServiceUnavailable) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ToolCallMetadata { pub tool_call_id: String, } ================================================ FILE: crates/executors/src/command.rs ================================================ use std::{collections::HashMap, path::PathBuf}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; use ts_rs::TS; use workspace_utils::shell::resolve_executable_path; use crate::executors::ExecutorError; #[derive(Debug, Error)] pub enum CommandBuildError { #[error("base command cannot be parsed: {0}")] InvalidBase(String), #[error("base command is empty after parsing")] EmptyCommand, #[error("failed to quote command: {0}")] QuoteError(#[from] shlex::QuoteError), #[error("invalid shell parameters: {0}")] InvalidShellParams(String), } #[derive(Debug, Clone)] pub struct CommandParts { program: String, args: Vec, } impl CommandParts { pub fn new(program: String, args: Vec) -> Self { Self { program, args } } pub async fn into_resolved(self) -> Result<(PathBuf, Vec), ExecutorError> { let CommandParts { program, args } = self; let executable = resolve_executable_path(&program) .await .ok_or(ExecutorError::ExecutableNotFound { program })?; Ok((executable, args)) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, Default)] pub struct CmdOverrides { #[schemars( title = "Base Command Override", description = "Override the base command with a custom command" )] #[serde(default, skip_serializing_if = "Option::is_none")] pub base_command_override: Option, #[schemars( title = "Additional Parameters", description = "Additional parameters to append to the base command" )] #[serde(default, skip_serializing_if = "Option::is_none")] pub additional_params: Option>, #[schemars( title = "Environment Variables", description = "Environment variables to set when running the executor" )] #[serde(default, skip_serializing_if = "Option::is_none")] pub env: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct CommandBuilder { /// Base executable command (e.g., "npx -y @anthropic-ai/claude-code@latest") pub base: String, /// Optional parameters to append to the base command pub params: Option>, } impl CommandBuilder { pub fn new>(base: S) -> Self { Self { base: base.into(), params: None, } } pub fn params(mut self, params: I) -> Self where I: IntoIterator, I::Item: Into, { self.params = Some(params.into_iter().map(|p| p.into()).collect()); self } pub fn override_base>(mut self, base: S) -> Self { self.base = base.into(); self } fn extend_shell_params(mut self, more: I) -> Result where I: IntoIterator, I::Item: Into, { let joined = more .into_iter() .map(|p| p.into()) .collect::>() .join(" "); if joined.trim().is_empty() { return Ok(self); } let extra: Vec = split_command_line(&joined) .map_err(|err| CommandBuildError::InvalidShellParams(format!("{joined}: {err}")))?; match &mut self.params { Some(p) => p.extend(extra), None => self.params = Some(extra), } Ok(self) } pub fn extend_params(mut self, more: I) -> Self where I: IntoIterator, I::Item: Into, { let extra: Vec = more.into_iter().map(|p| p.into()).collect(); match &mut self.params { Some(p) => p.extend(extra), None => self.params = Some(extra), } self } pub fn build_initial(&self) -> Result { self.build(&[]) } pub fn build_follow_up( &self, additional_args: &[String], ) -> Result { self.build(additional_args) } fn build(&self, additional_args: &[String]) -> Result { let mut parts = vec![]; let base_parts = split_command_line(&self.base)?; parts.extend(base_parts); if let Some(ref params) = self.params { parts.extend(params.clone()); } parts.extend(additional_args.iter().cloned()); if parts.is_empty() { return Err(CommandBuildError::EmptyCommand); } let program = parts.remove(0); Ok(CommandParts::new(program, parts)) } } fn split_command_line(input: &str) -> Result, CommandBuildError> { #[cfg(windows)] { let parts = winsplit::split(input); if parts.is_empty() { Err(CommandBuildError::EmptyCommand) } else { Ok(parts) } } #[cfg(not(windows))] { shlex::split(input).ok_or_else(|| CommandBuildError::InvalidBase(input.to_string())) } } pub fn apply_overrides( builder: CommandBuilder, overrides: &CmdOverrides, ) -> Result { let builder = if let Some(ref base) = overrides.base_command_override { builder.override_base(base.clone()) } else { builder }; if let Some(ref extra) = overrides.additional_params { builder.extend_shell_params(extra.clone()) } else { Ok(builder) } } ================================================ FILE: crates/executors/src/env.rs ================================================ use std::{collections::HashMap, path::PathBuf}; use git::GitService; use tokio::process::Command; use crate::command::CmdOverrides; /// Repository context for executor operations #[derive(Debug, Clone, Default)] pub struct RepoContext { pub workspace_root: PathBuf, /// Names of repositories in the workspace (subdirectory names) pub repo_names: Vec, } impl RepoContext { pub fn new(workspace_root: PathBuf, repo_names: Vec) -> Self { Self { workspace_root, repo_names, } } pub fn repo_paths(&self) -> Vec { self.repo_names .iter() .map(|name| self.workspace_root.join(name)) .collect() } /// Check all repos for uncommitted changes. /// Returns a formatted string describing any uncommitted changes found, /// or an empty string if all repos are clean. pub async fn check_uncommitted_changes(&self) -> String { let repo_paths = self.repo_paths(); if repo_paths.is_empty() { return String::new(); } tokio::task::spawn_blocking(move || { let git = GitService::new(); let mut all_status = String::new(); for repo_path in &repo_paths { // Skip if not a git repository if !repo_path.join(".git").exists() { continue; } match git.get_worktree_status(repo_path) { Ok(status) if !status.entries.is_empty() => { let mut status_output = String::new(); for entry in &status.entries { status_output.push(entry.staged); status_output.push(entry.unstaged); status_output.push(' '); status_output.push_str(&String::from_utf8_lossy(&entry.path)); status_output.push('\n'); } all_status.push_str(&format!( "\n{}:\n{}", repo_path.display(), status_output )); } _ => {} } } all_status }) .await .unwrap_or_default() } } /// Environment variables to inject into executor processes #[derive(Debug, Clone)] pub struct ExecutionEnv { pub vars: HashMap, pub repo_context: RepoContext, pub commit_reminder: bool, pub commit_reminder_prompt: String, } impl ExecutionEnv { pub fn new( repo_context: RepoContext, commit_reminder: bool, commit_reminder_prompt: String, ) -> Self { Self { vars: HashMap::new(), repo_context, commit_reminder, commit_reminder_prompt, } } /// Insert an environment variable pub fn insert(&mut self, key: impl Into, value: impl Into) { self.vars.insert(key.into(), value.into()); } /// Merge additional vars into this env. Incoming keys overwrite existing ones. pub fn merge(&mut self, other: &HashMap) { self.vars .extend(other.iter().map(|(k, v)| (k.clone(), v.clone()))); } /// Return a new env with overrides applied. Overrides take precedence. pub fn with_overrides(mut self, overrides: &HashMap) -> Self { self.merge(overrides); self } /// Return a new env with profile env from CmdOverrides merged in. pub fn with_profile(self, cmd: &CmdOverrides) -> Self { if let Some(ref profile_env) = cmd.env { self.with_overrides(profile_env) } else { self } } /// Apply all environment variables to a Command pub fn apply_to_command(&self, command: &mut Command) { for (key, value) in &self.vars { command.env(key, value); } } pub fn contains_key(&self, key: &str) -> bool { self.vars.contains_key(key) } pub fn get(&self, key: &str) -> Option<&String> { self.vars.get(key) } } #[cfg(test)] mod tests { use super::*; #[test] fn profile_overrides_runtime_env() { let mut base = ExecutionEnv::new(RepoContext::default(), false, String::new()); base.insert("VK_PROJECT_NAME", "runtime"); base.insert("FOO", "runtime"); let mut profile = HashMap::new(); profile.insert("FOO".to_string(), "profile".to_string()); profile.insert("BAR".to_string(), "profile".to_string()); let merged = base.with_overrides(&profile); assert_eq!(merged.vars.get("VK_PROJECT_NAME").unwrap(), "runtime"); assert_eq!(merged.vars.get("FOO").unwrap(), "profile"); // overrides assert_eq!(merged.vars.get("BAR").unwrap(), "profile"); } } ================================================ FILE: crates/executors/src/executor_discovery.rs ================================================ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::{ executors::{BaseCodingAgent, SlashCommandDescription}, model_selector::ModelSelectorConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] pub struct ExecutorDiscoveredOptions { pub model_selector: ModelSelectorConfig, pub slash_commands: Vec, pub loading_models: bool, pub loading_agents: bool, pub loading_slash_commands: bool, pub error: Option, } impl ExecutorDiscoveredOptions { pub fn with_loading(mut self, loading: bool) -> Self { self.loading_models = loading; self.loading_agents = loading; self.loading_slash_commands = loading; self } } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ExecutorConfigCacheKey { pub path: Option, pub cmd_key: String, pub base_executor: BaseCodingAgent, } impl ExecutorConfigCacheKey { pub fn new(path: Option<&PathBuf>, cmd_key: String, base_executor: BaseCodingAgent) -> Self { Self { path: path.cloned(), cmd_key, base_executor, } } } ================================================ FILE: crates/executors/src/executors/acp/client.rs ================================================ use std::sync::Arc; use agent_client_protocol::{self as acp}; use async_trait::async_trait; use tokio::sync::{Mutex, mpsc}; use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; use workspace_utils::approvals::ApprovalStatus; use crate::{ approvals::{ExecutorApprovalError, ExecutorApprovalService}, executors::acp::{AcpEvent, ApprovalResponse}, }; /// ACP client that handles agent-client protocol communication #[derive(Clone)] pub struct AcpClient { event_tx: mpsc::UnboundedSender, approvals: Option>, feedback_queue: Arc>>, cancel: CancellationToken, } impl AcpClient { /// Create a new ACP client pub fn new( event_tx: mpsc::UnboundedSender, approvals: Option>, cancel: CancellationToken, ) -> Self { Self { event_tx, approvals, feedback_queue: Arc::new(Mutex::new(Vec::new())), cancel, } } pub fn record_user_prompt_event(&self, prompt: &str) { self.send_event(AcpEvent::User(prompt.to_string())); } /// Send an event to the event channel fn send_event(&self, event: AcpEvent) { if let Err(e) = self.event_tx.send(event) { warn!("Failed to send ACP event: {}", e); } } /// Queue a user feedback message to be sent after a denial. pub async fn enqueue_feedback(&self, message: String) { let trimmed = message.trim().to_string(); if !trimmed.is_empty() { let mut q = self.feedback_queue.lock().await; q.push(trimmed); } } /// Drain and return queued feedback messages. pub async fn drain_feedback(&self) -> Vec { let mut q = self.feedback_queue.lock().await; q.drain(..).collect() } } #[async_trait(?Send)] impl acp::Client for AcpClient { async fn request_permission( &self, args: acp::RequestPermissionRequest, ) -> Result { self.send_event(AcpEvent::RequestPermission(args.clone())); if self.approvals.is_none() { // Auto-approve with best available option when no approval service is configured let chosen_option = args .options .iter() .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowAlways)) .or_else(|| { args.options .iter() .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce)) }) .or_else(|| args.options.first()); let outcome = if let Some(opt) = chosen_option { debug!("Auto-approving permission with option: {}", opt.option_id); acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( opt.option_id.clone(), )) } else { warn!("No permission options available, cancelling"); acp::RequestPermissionOutcome::Cancelled }; return Ok(acp::RequestPermissionResponse::new(outcome)); } let tool_call_id = args.tool_call.tool_call_id.0.to_string(); let tool_name = args.tool_call.fields.title.as_deref().unwrap_or("tool"); let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable) .map_err(|_| acp::Error::invalid_request())?; let approval_id = match approval_service.create_tool_approval(tool_name).await { Ok(id) => id, Err(err) => return self.handle_approval_error(err, &tool_call_id), }; self.send_event(AcpEvent::ApprovalRequested { tool_call_id: tool_call_id.clone(), approval_id: approval_id.clone(), }); let status = match approval_service .wait_tool_approval(&approval_id, self.cancel.clone()) .await { Ok(s) => s, Err(err) => return self.handle_approval_error(err, &tool_call_id), }; // Map our ApprovalStatus to ACP outcome let outcome = match &status { ApprovalStatus::Approved => { let chosen = args .options .iter() .find(|o| matches!(o.kind, acp::PermissionOptionKind::AllowOnce)); if let Some(opt) = chosen { acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( opt.option_id.clone(), )) } else { tracing::error!("No suitable approval option found, cancelling"); return Err(acp::Error::invalid_request()); } } ApprovalStatus::Denied { reason } => { // If user provided a reason, queue it to send after denial if let Some(feedback) = reason.as_ref() { self.enqueue_feedback(feedback.clone()).await; } let chosen = args .options .iter() .find(|o| matches!(o.kind, acp::PermissionOptionKind::RejectOnce)); if let Some(opt) = chosen { acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( opt.option_id.clone(), )) } else { warn!("No permission options for denial, cancelling"); acp::RequestPermissionOutcome::Cancelled } } ApprovalStatus::TimedOut => { warn!("Approval timed out"); acp::RequestPermissionOutcome::Cancelled } ApprovalStatus::Pending => { // This should not occur after waiter resolves warn!("Approval resolved to Pending"); acp::RequestPermissionOutcome::Cancelled } }; self.send_event(AcpEvent::ApprovalResponse(ApprovalResponse { tool_call_id: tool_call_id.clone(), status: status.clone(), })); Ok(acp::RequestPermissionResponse::new(outcome)) } async fn session_notification(&self, args: acp::SessionNotification) -> Result<(), acp::Error> { // Convert to typed events let event = match args.update { acp::SessionUpdate::AgentMessageChunk(chunk) => Some(AcpEvent::Message(chunk.content)), acp::SessionUpdate::AgentThoughtChunk(chunk) => Some(AcpEvent::Thought(chunk.content)), acp::SessionUpdate::ToolCall(tc) => Some(AcpEvent::ToolCall(tc)), acp::SessionUpdate::ToolCallUpdate(update) => Some(AcpEvent::ToolUpdate(update)), acp::SessionUpdate::Plan(plan) => Some(AcpEvent::Plan(plan)), _ => Some(AcpEvent::Other(args)), }; if let Some(event) = event { self.send_event(event); } Ok(()) } // File system operations - not implemented as we don't expose FS async fn write_text_file( &self, _args: acp::WriteTextFileRequest, ) -> Result { Err(acp::Error::method_not_found()) } async fn read_text_file( &self, _args: acp::ReadTextFileRequest, ) -> Result { Err(acp::Error::method_not_found()) } // Terminal operations - not implemented async fn create_terminal( &self, _args: acp::CreateTerminalRequest, ) -> Result { Err(acp::Error::method_not_found()) } async fn terminal_output( &self, _args: acp::TerminalOutputRequest, ) -> Result { Err(acp::Error::method_not_found()) } async fn release_terminal( &self, _args: acp::ReleaseTerminalRequest, ) -> Result { Err(acp::Error::method_not_found()) } async fn wait_for_terminal_exit( &self, _args: acp::WaitForTerminalExitRequest, ) -> Result { Err(acp::Error::method_not_found()) } async fn kill_terminal_command( &self, _args: acp::KillTerminalCommandRequest, ) -> Result { Err(acp::Error::method_not_found()) } // Extension methods async fn ext_method(&self, _args: acp::ExtRequest) -> Result { Err(acp::Error::method_not_found()) } async fn ext_notification(&self, _args: acp::ExtNotification) -> Result<(), acp::Error> { Ok(()) } } impl AcpClient { fn handle_approval_error( &self, err: ExecutorApprovalError, tool_call_id: &str, ) -> Result { if let ExecutorApprovalError::Cancelled = err { debug!("ACP approval cancelled for tool_call_id={}", tool_call_id); Ok(acp::RequestPermissionResponse::new( acp::RequestPermissionOutcome::Cancelled, )) } else { tracing::error!( "ACP approval wait failed for tool_call_id={}: {err}", tool_call_id ); self.send_event(AcpEvent::ApprovalResponse(ApprovalResponse { tool_call_id: tool_call_id.to_string(), status: ApprovalStatus::TimedOut, })); Err(acp::Error::internal_error()) } } } ================================================ FILE: crates/executors/src/executors/acp/harness.rs ================================================ use std::{ path::{Path, PathBuf}, process::Stdio, rc::Rc, sync::Arc, }; use agent_client_protocol as proto; use agent_client_protocol::Agent as _; use command_group::AsyncGroupChild; use futures::StreamExt; use tokio::{io::AsyncWriteExt, process::Command, sync::mpsc}; use tokio_util::{ compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}, io::ReaderStream, sync::CancellationToken, }; use tracing::error; use workspace_utils::{ approvals::ApprovalStatus, command_ext::GroupSpawnNoWindowExt, stream_lines::LinesStreamExt, }; use super::{AcpClient, SessionManager}; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandParts}, env::ExecutionEnv, executors::{ExecutorError, ExecutorExitResult, SpawnedChild, acp::AcpEvent}, }; /// Reusable harness for ACP-based conns (Gemini, Qwen, etc.) pub struct AcpAgentHarness { session_namespace: String, model: Option, mode: Option, } impl Default for AcpAgentHarness { fn default() -> Self { // Keep existing behavior for Gemini Self::new() } } impl AcpAgentHarness { /// Create a harness with the default Gemini namespace pub fn new() -> Self { Self { session_namespace: "gemini_sessions".to_string(), model: None, mode: None, } } /// Create a harness with a custom session namespace (e.g. for Qwen) pub fn with_session_namespace(namespace: impl Into) -> Self { Self { session_namespace: namespace.into(), model: None, mode: None, } } pub fn with_model(mut self, model: impl Into) -> Self { self.model = Some(model.into()); self } pub fn with_mode(mut self, mode: impl Into) -> Self { self.mode = Some(mode.into()); self } pub fn apply_overrides(&mut self, executor_config: &crate::profile::ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(agent_id) = &executor_config.agent_id { self.mode = Some(agent_id.clone()); } } pub async fn spawn_with_command( &self, current_dir: &Path, prompt: String, command_parts: CommandParts, env: &ExecutionEnv, cmd_overrides: &CmdOverrides, approvals: Option>, ) -> Result { let (program_path, args) = command_parts.into_resolved().await?; let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .env("NODE_NO_WARNINGS", "1") .args(&args); env.clone() .with_profile(cmd_overrides) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::(); let cancel = CancellationToken::new(); Self::bootstrap_acp_connection( &mut child, current_dir.to_path_buf(), None, prompt, Some(exit_tx), self.session_namespace.clone(), self.model.clone(), self.mode.clone(), approvals, cancel.clone(), ) .await?; Ok(SpawnedChild { child, exit_signal: Some(exit_rx), cancel: Some(cancel), }) } #[allow(clippy::too_many_arguments)] pub async fn spawn_follow_up_with_command( &self, current_dir: &Path, prompt: String, session_id: &str, command_parts: CommandParts, env: &ExecutionEnv, cmd_overrides: &CmdOverrides, approvals: Option>, ) -> Result { let (program_path, args) = command_parts.into_resolved().await?; let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .env("NODE_NO_WARNINGS", "1") .args(&args); env.clone() .with_profile(cmd_overrides) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::(); let cancel = CancellationToken::new(); Self::bootstrap_acp_connection( &mut child, current_dir.to_path_buf(), Some(session_id.to_string()), prompt, Some(exit_tx), self.session_namespace.clone(), self.model.clone(), self.mode.clone(), approvals, cancel.clone(), ) .await?; Ok(SpawnedChild { child, exit_signal: Some(exit_rx), cancel: Some(cancel), }) } #[allow(clippy::too_many_arguments)] async fn bootstrap_acp_connection( child: &mut AsyncGroupChild, cwd: PathBuf, existing_session: Option, prompt: String, exit_signal: Option>, session_namespace: String, model: Option, mode: Option, approvals: Option>, cancel: CancellationToken, ) -> Result<(), ExecutorError> { // Take child's stdio for ACP wiring let orig_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, "Child process has no stdout", )) })?; let orig_stdin = child.inner().stdin.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, "Child process has no stdin", )) })?; // Create a fresh stdout pipe for logs let writer = crate::stdout_dup::create_stdout_pipe_writer(child)?; let shared_writer = Arc::new(tokio::sync::Mutex::new(writer)); let (log_tx, mut log_rx) = mpsc::unbounded_channel::(); // Spawn log -> stdout writer task tokio::spawn(async move { while let Some(line) = log_rx.recv().await { let mut data = line.into_bytes(); data.push(b'\n'); let mut w = shared_writer.lock().await; let _ = w.write_all(&data).await; } }); // ACP client STDIO let (mut to_acp_writer, acp_incoming_reader) = tokio::io::duplex(64 * 1024); let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); // Process stdout -> ACP let stdout_shutdown_rx = shutdown_rx.clone(); tokio::spawn(async move { let mut stdout_stream = ReaderStream::new(orig_stdout); while let Some(res) = stdout_stream.next().await { if *stdout_shutdown_rx.borrow() { break; } match res { Ok(data) => { let _ = to_acp_writer.write_all(&data).await; } Err(_) => break, } } }); // ACP crate expects futures::AsyncRead + AsyncWrite, use tokio compat to adapt tokio::io::AsyncRead + Write let (acp_out_writer, acp_out_reader) = tokio::io::duplex(64 * 1024); let outgoing = acp_out_writer.compat_write(); let incoming = acp_incoming_reader.compat(); // Process ACP -> stdin let stdin_shutdown_rx = shutdown_rx.clone(); tokio::spawn(async move { let mut child_stdin = orig_stdin; let mut lines = ReaderStream::new(acp_out_reader) .map(|res| res.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())) .lines(); while let Some(result) = lines.next().await { if *stdin_shutdown_rx.borrow() { break; } match result { Ok(line) => { // Use \r\n on Windows for compatibility with buggy ACP implementations const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" }; let line = line + LINE_ENDING; if let Err(err) = child_stdin.write_all(line.as_bytes()).await { tracing::debug!("Failed to write to child stdin {err}"); break; } let _ = child_stdin.flush().await; } Err(err) => { tracing::debug!("ACP stdin line error {err}"); break; } } } }); let mut exit_signal_tx = exit_signal; // Run ACP client in a LocalSet tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("build runtime"); rt.block_on(async move { let local = tokio::task::LocalSet::new(); local .run_until(async move { // Create event and raw channels // Typed events available for future use; raw lines forwarded and persisted let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); // Create session manager let session_manager = match SessionManager::new(session_namespace) { Ok(sm) => sm, Err(e) => { error!("Failed to create session manager: {}", e); return; } }; let session_manager = std::sync::Arc::new(session_manager); // Create ACP client with approvals support let client = AcpClient::new(event_tx.clone(), approvals.clone(), cancel.clone()); let client_feedback_handle = client.clone(); client.record_user_prompt_event(&prompt); // Set up connection let (conn, io_fut) = proto::ClientSideConnection::new(client, outgoing, incoming, |fut| { tokio::task::spawn_local(fut); }); let conn = Rc::new(conn); // Drive I/O let io_handle = tokio::task::spawn_local(async move { let _ = io_fut.await; }); // Initialize let _ = conn .initialize(proto::InitializeRequest::new(proto::ProtocolVersion::V1)) .await; // Handle session creation/forking let (acp_session_id, display_session_id, prompt_to_send) = if let Some(existing) = existing_session { // Fork existing session let new_ui_id = uuid::Uuid::new_v4().to_string(); let _ = session_manager.fork_session(&existing, &new_ui_id); let history = session_manager.read_session_raw(&new_ui_id).ok(); let meta = history.map(|h| serde_json::json!({ "history_jsonl": h })); let mut req = proto::NewSessionRequest::new(cwd.clone()); if let Some(m) = meta && let Some(obj) = m.as_object() { req = req.meta(obj.clone()); } match conn.new_session(req).await { Ok(resp) => { let resume_prompt = session_manager .generate_resume_prompt(&new_ui_id, &prompt) .unwrap_or_else(|_| prompt.clone()); (resp.session_id.0.to_string(), new_ui_id, resume_prompt) } Err(e) => { error!("Failed to create session: {}", e); return; } } } else { // New session match conn .new_session(proto::NewSessionRequest::new(cwd.clone())) .await { Ok(resp) => { let sid = resp.session_id.0.to_string(); (sid.clone(), sid, prompt) } Err(e) => { error!("Failed to create session: {}", e); return; } } }; // Emit session ID let _ = log_tx .send(AcpEvent::SessionStart(display_session_id.clone()).to_string()); if let Some(model) = model.clone() { match conn .set_session_model(proto::SetSessionModelRequest::new( proto::SessionId::new(acp_session_id.clone()), model, )) .await { Ok(_) => {} Err(e) => error!("Failed to set session mode: {}", e), } } if let Some(mode) = mode.clone() { match conn .set_session_mode(proto::SetSessionModeRequest::new( proto::SessionId::new(acp_session_id.clone()), mode, )) .await { Ok(_) => {} Err(e) => error!("Failed to set session mode: {}", e), } } // Start raw event forwarder and persistence let app_tx_clone = log_tx.clone(); let sess_id_for_writer = display_session_id.clone(); let sm_for_writer = session_manager.clone(); let conn_for_cancel = conn.clone(); let acp_session_id_for_cancel = acp_session_id.clone(); tokio::task::spawn_local(async move { while let Some(event) = event_rx.recv().await { if let AcpEvent::ApprovalResponse(resp) = &event && let ApprovalStatus::Denied { reason: Some(reason), } = &resp.status && !reason.trim().is_empty() { let _ = conn_for_cancel .cancel(proto::CancelNotification::new( proto::SessionId::new( acp_session_id_for_cancel.clone(), ), )) .await; } let line = event.to_string(); // Forward to stdout let _ = app_tx_clone.send(line.clone()); // Persist to session file let _ = sm_for_writer.append_raw_line(&sess_id_for_writer, &line); } }); // Save prompt to session let _ = session_manager.append_raw_line( &display_session_id, &serde_json::to_string(&serde_json::json!({ "user": prompt_to_send })) .unwrap_or_default(), ); // Build prompt request let initial_req = proto::PromptRequest::new( proto::SessionId::new(acp_session_id.clone()), vec![proto::ContentBlock::Text(proto::TextContent::new( prompt_to_send, ))], ); let mut current_req = Some(initial_req); while let Some(req) = current_req.take() { if cancel.is_cancelled() { tracing::debug!("ACP executor cancelled, stopping prompt loop"); break; } tracing::trace!(?req, "sending ACP prompt request"); // Send the prompt and await completion to obtain stop_reason let prompt_result = tokio::select! { _ = cancel.cancelled() => { tracing::debug!("ACP executor cancelled during prompt"); break; } result = conn.prompt(req) => result, }; match prompt_result { Ok(resp) => { // Emit done with stop_reason let stop_reason = serde_json::to_string(&resp.stop_reason) .unwrap_or_default(); let _ = log_tx.send(AcpEvent::Done(stop_reason).to_string()); } Err(e) => { tracing::debug!("error {} {e} {:?}", e.code, e.data); if e.code == agent_client_protocol::ErrorCode::INTERNAL_ERROR.code && e.data .as_ref() .is_some_and(|d| d == "server shut down unexpectedly") { tracing::debug!("ACP server killed"); } else { let _ = log_tx .send(AcpEvent::Error(format!("{e}")).to_string()); } } } // Flush any pending user feedback after finish let feedback = client_feedback_handle .drain_feedback() .await .join("\n") .trim() .to_string(); if !feedback.is_empty() { tracing::trace!(?feedback, "sending ACP follow-up feedback"); let session_id = proto::SessionId::new(acp_session_id.clone()); let feedback_req = proto::PromptRequest::new( session_id.clone(), vec![proto::ContentBlock::Text(proto::TextContent::new( feedback, ))], ); current_req = Some(feedback_req); } } // Notify container of completion if let Some(tx) = exit_signal_tx.take() { let _ = tx.send(ExecutorExitResult::Success); } // Cancel session work let _ = conn .cancel(proto::CancelNotification::new(proto::SessionId::new( acp_session_id, ))) .await; // Cleanup drop(conn); let _ = shutdown_tx.send(true); let _ = io_handle.await; drop(log_tx); }) .await; }); }); Ok(()) } } ================================================ FILE: crates/executors/src/executors/acp/mod.rs ================================================ pub mod client; pub mod harness; pub mod normalize_logs; pub mod session; use std::{fmt::Display, str::FromStr}; pub use client::AcpClient; pub use harness::AcpAgentHarness; pub use normalize_logs::*; use serde::{Deserialize, Serialize}; pub use session::SessionManager; use workspace_utils::approvals::ApprovalStatus; /// Parsed event types for internal processing #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AcpEvent { User(String), SessionStart(String), Message(agent_client_protocol::ContentBlock), Thought(agent_client_protocol::ContentBlock), ToolCall(agent_client_protocol::ToolCall), ToolUpdate(agent_client_protocol::ToolCallUpdate), Plan(agent_client_protocol::Plan), AvailableCommands(Vec), CurrentMode(agent_client_protocol::SessionModeId), RequestPermission(agent_client_protocol::RequestPermissionRequest), ApprovalRequested { tool_call_id: String, approval_id: String, }, ApprovalResponse(ApprovalResponse), Error(String), Done(String), Other(agent_client_protocol::SessionNotification), } impl Display for AcpEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", serde_json::to_string(self).unwrap_or_default()) } } impl FromStr for AcpEvent { type Err = serde_json::Error; fn from_str(s: &str) -> Result { serde_json::from_str(s) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApprovalResponse { pub tool_call_id: String, pub status: ApprovalStatus, } ================================================ FILE: crates/executors/src/executors/acp/normalize_logs.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, sync::{Arc, LazyLock}, time::Duration, }; use agent_client_protocol::{self as acp, SessionNotification}; use futures::StreamExt; use regex::Regex; use serde::Deserialize; use workspace_utils::{approvals::ApprovalStatus, msg_store::MsgStore}; pub use super::AcpAgentHarness; use super::AcpEvent; use crate::{ approvals::ToolCallMetadata, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolResult, ToolResultValueType, ToolStatus as LogToolStatus, plain_text_processor::PlainTextLogProcessor, stderr_processor::normalize_stderr_logs, utils::{ConversationPatch, EntryIndexProvider, shell_command_parsing::CommandCategory}, }, }; pub fn normalize_logs( msg_store: Arc, worktree_path: &Path, ) -> Vec> { normalize_logs_with_suppressed_stderr_patterns(msg_store, worktree_path, &[]) } pub fn normalize_logs_with_suppressed_stderr_patterns( msg_store: Arc, worktree_path: &Path, suppressed_stderr_patterns: &[&str], ) -> Vec> { // stderr normalization let entry_index = EntryIndexProvider::start_from(&msg_store); let h1 = if suppressed_stderr_patterns.is_empty() { normalize_stderr_logs(msg_store.clone(), entry_index.clone()) } else { normalize_acp_stderr_logs( msg_store.clone(), entry_index.clone(), suppressed_stderr_patterns .iter() .map(|pattern| pattern.to_string()) .collect(), ) }; // stdout normalization (main loop) let worktree_path = worktree_path.to_path_buf(); // Type aliases to simplify complex state types and appease clippy let h2 = tokio::spawn(async move { type ToolStates = std::collections::HashMap; let mut stored_session_id = false; let mut streaming: StreamingState = StreamingState::default(); let mut tool_states: ToolStates = HashMap::new(); let mut stdout_lines = msg_store.stdout_lines_stream(); while let Some(Ok(line)) = stdout_lines.next().await { if let Some(parsed) = AcpEventParser::parse_line(&line) { tracing::trace!("Parsed ACP line: {:?}", parsed); match parsed { AcpEvent::SessionStart(id) => { if !stored_session_id { msg_store.push_session_id(id); stored_session_id = true; } } AcpEvent::Error(msg) => { let idx = entry_index.next(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: msg, metadata: None, }; msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry)); } AcpEvent::Done(_) => { streaming.assistant_text = None; streaming.thinking_text = None; } AcpEvent::Message(content) => { streaming.thinking_text = None; if let agent_client_protocol::ContentBlock::Text(text) = content { let is_new = streaming.assistant_text.is_none(); if is_new { if text.text == "\n" { continue; } let idx = entry_index.next(); streaming.assistant_text = Some(StreamingText { index: idx, content: String::new(), }); } if let Some(ref mut s) = streaming.assistant_text { s.content.push_str(&text.text); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::AssistantMessage, content: s.content.clone(), metadata: None, }; let patch = if is_new { ConversationPatch::add_normalized_entry(s.index, entry) } else { ConversationPatch::replace(s.index, entry) }; msg_store.push_patch(patch); } } } AcpEvent::Thought(content) => { streaming.assistant_text = None; if let agent_client_protocol::ContentBlock::Text(text) = content { let is_new = streaming.thinking_text.is_none(); if is_new { let idx = entry_index.next(); streaming.thinking_text = Some(StreamingText { index: idx, content: String::new(), }); } if let Some(ref mut s) = streaming.thinking_text { s.content.push_str(&text.text); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::Thinking, content: s.content.clone(), metadata: None, }; let patch = if is_new { ConversationPatch::add_normalized_entry(s.index, entry) } else { ConversationPatch::replace(s.index, entry) }; msg_store.push_patch(patch); } } } AcpEvent::Plan(plan) => { streaming.assistant_text = None; streaming.thinking_text = None; let todos: Vec = plan .entries .iter() .map(|e| TodoItem { content: e.content.clone(), status: serde_json::to_value(&e.status) .ok() .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_else(|| "unknown".to_string()), priority: serde_json::to_value(&e.priority) .ok() .and_then(|v| v.as_str().map(|s| s.to_string())), }) .collect(); let idx = entry_index.next(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "plan".to_string(), action_type: ActionType::TodoManagement { todos, operation: "update".to_string(), }, status: LogToolStatus::Success, }, content: "Plan updated".to_string(), metadata: None, }; msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry)); } AcpEvent::AvailableCommands(cmds) => { let mut body = String::from("Available commands:\n"); for c in &cmds { body.push_str(&format!("- {}\n", c.name)); } let idx = entry_index.next(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: body, metadata: None, }; msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry)); } AcpEvent::CurrentMode(mode_id) => { let idx = entry_index.next(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("Current mode: {}", mode_id.0), metadata: None, }; msg_store.push_patch(ConversationPatch::add_normalized_entry(idx, entry)); } AcpEvent::RequestPermission(perm) => { if let Ok(tc) = agent_client_protocol::ToolCall::try_from(perm.tool_call) { handle_tool_call( &tc, &worktree_path, &mut streaming, &mut tool_states, &entry_index, &msg_store, ); } } AcpEvent::ToolCall(tc) => handle_tool_call( &tc, &worktree_path, &mut streaming, &mut tool_states, &entry_index, &msg_store, ), AcpEvent::ToolUpdate(update) => { let mut update = update; if update.fields.title.is_none() { update.fields.title = tool_states .get(&update.tool_call_id.0.to_string()) .map(|s| s.title.clone()) .or_else(|| Some("".to_string())); } tracing::trace!("Got tool call update: {:?}", update); if let Ok(tc) = agent_client_protocol::ToolCall::try_from(update.clone()) { handle_tool_call( &tc, &worktree_path, &mut streaming, &mut tool_states, &entry_index, &msg_store, ); } else { tracing::debug!("Failed to convert tool call update to ToolCall"); } } AcpEvent::ApprovalRequested { tool_call_id, approval_id, } => { if let Some(tool_data) = tool_states.get(&tool_call_id) { let action = map_to_action_type(tool_data); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: tool_data.title.clone(), action_type: action, status: LogToolStatus::PendingApproval { approval_id }, }, content: get_tool_content(tool_data), metadata: None, }; msg_store .push_patch(ConversationPatch::replace(tool_data.index, entry)); } } AcpEvent::ApprovalResponse(resp) => { tracing::trace!("Received approval response: {:?}", resp); if let Some(tool_data) = tool_states.get(&resp.tool_call_id) { let new_status = LogToolStatus::from_approval_status(&resp.status); if let Some(status) = new_status { let action = map_to_action_type(tool_data); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: tool_data.title.clone(), action_type: action, status, }, content: get_tool_content(tool_data), metadata: serde_json::to_value(ToolCallMetadata { tool_call_id: tool_data.id.0.to_string(), }) .ok(), }; msg_store .push_patch(ConversationPatch::replace(tool_data.index, entry)); } } if let ApprovalStatus::Denied { reason } = resp.status { let tool_name = tool_states .get(&resp.tool_call_id) .map(|t| { extract_tool_name_from_id(t.id.0.as_ref()) .unwrap_or_else(|| t.title.clone()) }) .unwrap_or_default(); let idx = entry_index.next(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserFeedback { denied_tool: tool_name, }, content: reason .clone() .unwrap_or_else(|| { "User denied this tool use request".to_string() }) .trim() .to_string(), metadata: None, }; msg_store .push_patch(ConversationPatch::add_normalized_entry(idx, entry)); } } AcpEvent::User(_) | AcpEvent::Other(_) => (), } } } fn handle_tool_call( tc: &agent_client_protocol::ToolCall, worktree_path: &Path, streaming: &mut StreamingState, tool_states: &mut ToolStates, entry_index: &EntryIndexProvider, msg_store: &Arc, ) { streaming.assistant_text = None; streaming.thinking_text = None; let id = tc.tool_call_id.0.to_string(); let is_new = !tool_states.contains_key(&id); let tool_data = tool_states.entry(id).or_default(); tool_data.extend(tc, worktree_path); if is_new { tool_data.index = entry_index.next(); } let action = map_to_action_type(tool_data); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: tool_data.title.clone(), action_type: action, status: convert_tool_status(&tool_data.status), }, content: get_tool_content(tool_data), metadata: serde_json::to_value(ToolCallMetadata { tool_call_id: tool_data.id.0.to_string(), }) .ok(), }; let patch = if is_new { ConversationPatch::add_normalized_entry(tool_data.index, entry) } else { ConversationPatch::replace(tool_data.index, entry) }; msg_store.push_patch(patch); } fn map_to_action_type(tc: &PartialToolCallData) -> ActionType { match tc.kind { agent_client_protocol::ToolKind::Read => { // Special-case: read_many_files style titles parsed via helper if tc.id.0.starts_with("read_many_files") { let result = collect_text_content(&tc.content).map(|text| ToolResult { r#type: ToolResultValueType::Markdown, value: serde_json::Value::String(text), }); return ActionType::Tool { tool_name: "read_many_files".to_string(), arguments: Some(serde_json::Value::String(tc.title.clone())), result, }; } ActionType::FileRead { path: tc .path .clone() .unwrap_or_default() .to_string_lossy() .to_string(), } } agent_client_protocol::ToolKind::Edit => { let changes = extract_file_changes(tc); ActionType::FileEdit { path: tc .path .clone() .unwrap_or_default() .to_string_lossy() .to_string(), changes, } } agent_client_protocol::ToolKind::Execute => { let command = AcpEventParser::parse_execute_command(tc); // Prefer structured raw_output, else fallback to aggregated text content let completed = matches!(tc.status, agent_client_protocol::ToolCallStatus::Completed); tracing::trace!( "Mapping execute tool call, completed: {}, command: {}", completed, command ); let tc_exit_status = match tc.status { agent_client_protocol::ToolCallStatus::Completed => { Some(crate::logs::CommandExitStatus::Success { success: true }) } agent_client_protocol::ToolCallStatus::Failed => { Some(crate::logs::CommandExitStatus::Success { success: false }) } _ => None, }; let result = if let Some(text) = collect_text_content(&tc.content) { Some(crate::logs::CommandRunResult { exit_status: tc_exit_status, output: Some(text), }) } else { Some(crate::logs::CommandRunResult { exit_status: tc_exit_status, output: None, }) }; ActionType::CommandRun { command: command.clone(), result, category: CommandCategory::from_command(&command), } } agent_client_protocol::ToolKind::Delete => ActionType::FileEdit { path: tc .path .clone() .unwrap_or_default() .to_string_lossy() .to_string(), changes: vec![FileChange::Delete], }, agent_client_protocol::ToolKind::Search => { let query = tc .raw_input .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) .map(|a| a.query) .unwrap_or_else(|| tc.title.clone()); ActionType::Search { query } } agent_client_protocol::ToolKind::Fetch => { let mut url = tc .raw_input .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) .map(|a| a.url) .unwrap_or_default(); if url.is_empty() { // Fallback: try to extract first URL from the title if let Some(extracted) = extract_url_from_text(&tc.title) { url = extracted; } } ActionType::WebFetch { url } } agent_client_protocol::ToolKind::Think => { let tool_name = extract_tool_name_from_id(tc.id.0.as_ref()) .unwrap_or_else(|| tc.title.clone()); // For think/save_memory, surface both title and aggregated text content as arguments let text = collect_text_content(&tc.content); let arguments = Some(match &text { Some(t) => serde_json::json!({ "title": tc.title, "content": t }), None => serde_json::json!({ "title": tc.title }), }); let result = if let Some(output) = &tc.raw_output { Some(ToolResult { r#type: ToolResultValueType::Json, value: output.clone(), }) } else { collect_text_content(&tc.content).map(|text| ToolResult { r#type: ToolResultValueType::Markdown, value: serde_json::Value::String(text), }) }; ActionType::Tool { tool_name, arguments, result, } } agent_client_protocol::ToolKind::SwitchMode => ActionType::Other { description: "switch_mode".to_string(), }, agent_client_protocol::ToolKind::Other | agent_client_protocol::ToolKind::Move | _ => { // Derive a friendlier tool name from the id if it looks like name- let tool_name = extract_tool_name_from_id(tc.id.0.as_ref()) .unwrap_or_else(|| tc.title.clone()); // Some tools embed JSON args into the title instead of raw_input let arguments = if let Some(raw) = &tc.raw_input { Some(raw.clone()) } else if tc.title.trim_start().starts_with('{') { // Title contains JSON arguments for the tool serde_json::from_str::(&tc.title).ok() } else { None }; // Extract result: prefer raw_output (structured), else text content as Markdown let result = if let Some(output) = &tc.raw_output { Some(ToolResult { r#type: ToolResultValueType::Json, value: output.clone(), }) } else { collect_text_content(&tc.content).map(|text| ToolResult { r#type: ToolResultValueType::Markdown, value: serde_json::Value::String(text), }) }; ActionType::Tool { tool_name, arguments, result, } } } } fn extract_file_changes(tc: &PartialToolCallData) -> Vec { let mut changes = Vec::new(); for c in &tc.content { if let agent_client_protocol::ToolCallContent::Diff(diff) = c { let path = diff.path.to_string_lossy().to_string(); let rel = if !path.is_empty() { path } else { tc.path .clone() .unwrap_or_default() .to_string_lossy() .to_string() }; let old_text = diff.old_text.as_deref().unwrap_or(""); if old_text.is_empty() { changes.push(FileChange::Write { content: diff.new_text.clone(), }); } else { let unified = workspace_utils::diff::create_unified_diff( &rel, old_text, &diff.new_text, ); changes.push(FileChange::Edit { unified_diff: unified, has_line_numbers: false, }); } } } if changes.is_empty() && let Some(raw) = &tc.raw_input && let Ok(edit_input) = serde_json::from_value::(raw.clone()) { if let Some(diff) = edit_input.diff { changes.push(FileChange::Edit { unified_diff: workspace_utils::diff::normalize_unified_diff( &edit_input.file_path, &diff, ), has_line_numbers: true, }); } else if let Some(old) = edit_input.old_string && let Some(new) = edit_input.new_string { changes.push(FileChange::Edit { unified_diff: workspace_utils::diff::create_unified_diff( &edit_input.file_path, &old, &new, ), has_line_numbers: false, }); } } changes } fn get_tool_content(tc: &PartialToolCallData) -> String { match tc.kind { agent_client_protocol::ToolKind::Execute => { AcpEventParser::parse_execute_command(tc) } agent_client_protocol::ToolKind::Think => "Saving memory".to_string(), agent_client_protocol::ToolKind::Other => { let tool_name = extract_tool_name_from_id(tc.id.0.as_ref()) .unwrap_or_else(|| "tool".to_string()); if tc.title.is_empty() { tool_name } else { format!("{}: {}", tool_name, tc.title) } } agent_client_protocol::ToolKind::Read => { if tc.id.0.starts_with("read_many_files") { "Read files".to_string() } else { tc.path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| tc.title.clone()) } } _ => tc.title.clone(), } } fn extract_tool_name_from_id(id: &str) -> Option { if let Some(idx) = id.rfind('-') { let (head, tail) = id.split_at(idx); if tail .trim_start_matches('-') .chars() .all(|c| c.is_ascii_digit()) { return Some(head.to_string()); } } None } fn extract_url_from_text(text: &str) -> Option { // Simple URL extractor static URL_RE: LazyLock = LazyLock::new(|| Regex::new(r#"https?://[^\s"')]+"#).expect("valid regex")); URL_RE.find(text).map(|m| m.as_str().to_string()) } fn collect_text_content( content: &[agent_client_protocol::ToolCallContent], ) -> Option { let mut out = String::new(); for c in content { if let agent_client_protocol::ToolCallContent::Content(inner) = c && let agent_client_protocol::ContentBlock::Text(t) = &inner.content { out.push_str(&t.text); if !out.ends_with('\n') { out.push('\n'); } } } if out.is_empty() { None } else { Some(out) } } fn convert_tool_status(status: &agent_client_protocol::ToolCallStatus) -> LogToolStatus { match status { agent_client_protocol::ToolCallStatus::Pending | agent_client_protocol::ToolCallStatus::InProgress => LogToolStatus::Created, agent_client_protocol::ToolCallStatus::Completed => LogToolStatus::Success, agent_client_protocol::ToolCallStatus::Failed => LogToolStatus::Failed, _ => { tracing::debug!("Unknown tool call status: {:?}", status); LogToolStatus::Created } } } }); vec![h1, h2] } fn normalize_acp_stderr_logs( msg_store: Arc, entry_index_provider: EntryIndexProvider, suppressed_patterns: Vec, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stderr = msg_store.stderr_chunked_stream(); let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(Box::new(|content: String| NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: strip_ansi_escapes::strip_str(&content), metadata: None, })) .time_gap(Duration::from_secs(2)) .index_provider(entry_index_provider) .transform_lines(Box::new(move |lines: &mut Vec| { lines.retain(|line| { !suppressed_patterns .iter() .any(|pattern| line.contains(pattern)) }); })) .build(); while let Some(Ok(chunk)) = stderr.next().await { for patch in processor.process(chunk) { msg_store.push_patch(patch); } } }) } struct PartialToolCallData { index: usize, id: agent_client_protocol::ToolCallId, kind: agent_client_protocol::ToolKind, title: String, status: agent_client_protocol::ToolCallStatus, path: Option, content: Vec, raw_input: Option, raw_output: Option, } impl PartialToolCallData { fn extend(&mut self, tc: &agent_client_protocol::ToolCall, worktree_path: &Path) { self.id = tc.tool_call_id.clone(); if tc.kind != Default::default() { self.kind = tc.kind; } if !tc.title.is_empty() { self.title = tc.title.clone(); } if tc.status != Default::default() { self.status = tc.status; } if !tc.locations.is_empty() { self.path = tc.locations.first().map(|l| { PathBuf::from(workspace_utils::path::make_path_relative( &l.path.to_string_lossy(), &worktree_path.to_string_lossy(), )) }); } if !tc.content.is_empty() { self.content = tc.content.clone(); } if tc.raw_input.is_some() { self.raw_input = tc.raw_input.clone(); } if tc.raw_output.is_some() { self.raw_output = tc.raw_output.clone(); } } } impl Default for PartialToolCallData { fn default() -> Self { Self { id: agent_client_protocol::ToolCallId::new(""), index: 0, kind: agent_client_protocol::ToolKind::default(), title: String::new(), status: Default::default(), path: None, content: Vec::new(), raw_input: None, raw_output: None, } } } struct AcpEventParser; impl AcpEventParser { /// Parse a line that may contain an ACP event pub fn parse_line(line: &str) -> Option { let trimmed = line.trim(); if let Ok(acp_event) = serde_json::from_str::(trimmed) { return Some(acp_event); } tracing::debug!("Failed to parse ACP raw log {trimmed}"); None } /// Parse command from tool title (for execute tools) pub fn parse_execute_command(tc: &PartialToolCallData) -> String { if let Some(command) = tc.raw_input.as_ref().and_then(|value| { value .as_object() .and_then(|o| o.get("command").and_then(|v| v.as_str())) }) { return command.to_string(); } let title = &tc.title; if let Some(command) = title.split(" [current working directory ").next() { command.trim().to_string() } else if let Some(command) = title.split(" (").next() { command.trim().to_string() } else { title.trim().to_string() } } } /// Result of parsing a line #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum ParsedLine { SessionId(String), Event(AcpEvent), Error(String), Done, } impl TryFrom for AcpEvent { type Error = (); fn try_from(notification: SessionNotification) -> Result { let event = match notification.update { acp::SessionUpdate::AgentMessageChunk(chunk) => AcpEvent::Message(chunk.content), acp::SessionUpdate::AgentThoughtChunk(chunk) => AcpEvent::Thought(chunk.content), acp::SessionUpdate::ToolCall(tc) => AcpEvent::ToolCall(tc), acp::SessionUpdate::ToolCallUpdate(update) => AcpEvent::ToolUpdate(update), acp::SessionUpdate::Plan(plan) => AcpEvent::Plan(plan), acp::SessionUpdate::AvailableCommandsUpdate(update) => { AcpEvent::AvailableCommands(update.available_commands) } acp::SessionUpdate::CurrentModeUpdate(update) => { AcpEvent::CurrentMode(update.current_mode_id) } _ => return Err(()), }; Ok(event) } } #[derive(Debug, Clone, Deserialize)] struct SearchArgs { query: String, } #[derive(Debug, Clone, Deserialize)] struct FetchArgs { url: String, } #[derive(Debug, Clone, Default)] struct StreamingState { assistant_text: Option, thinking_text: Option, } #[derive(Debug, Clone)] struct StreamingText { index: usize, content: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct EditInput { file_path: String, #[serde(default)] diff: Option, #[serde(default)] old_string: Option, #[serde(default)] new_string: Option, } ================================================ FILE: crates/executors/src/executors/acp/session.rs ================================================ use std::{ fs::{self, OpenOptions}, io::{self, Result, Write}, path::PathBuf, str::FromStr, }; use serde::{Deserialize, Serialize}; use crate::executors::acp::AcpEvent; /// Manages session persistence and state for ACP interactions pub struct SessionManager { base_dir: PathBuf, } impl SessionManager { /// Create a new session manager with the given namespace pub fn new(namespace: impl Into) -> Result { let namespace = namespace.into(); let mut vk_dir = dirs::home_dir() .ok_or_else(|| io::Error::other("Could not determine home directory"))? .join(".vibe-kanban"); if cfg!(debug_assertions) { vk_dir = vk_dir.join("dev"); } let base_dir = vk_dir.join(&namespace); fs::create_dir_all(&base_dir)?; Ok(Self { base_dir }) } /// Get the file path for a session fn session_file_path(&self, session_id: &str) -> PathBuf { self.base_dir.join(format!("{session_id}.jsonl")) } /// Append a raw JSON line to the session log /// /// We normalize ACP payloads by: /// - Removing top-level `sessionId` /// - Unwrapping the `update` envelope (store its object directly) /// - Dropping top-level `options` (permission menu). Note: `options` is /// mutually exclusive with `update`, so when `update` is present we do not /// perform any `options` stripping. pub fn append_raw_line(&self, session_id: &str, raw_json: &str) -> Result<()> { let Some(normalized) = Self::normalize_session_event(raw_json) else { return Ok(()); }; let path = self.session_file_path(session_id); let mut file = OpenOptions::new().create(true).append(true).open(path)?; writeln!(file, "{normalized}")?; Ok(()) } /// Attempt to normalize a raw ACP JSON event into a cleaner shape. /// Rules: /// - Remove top-level `sessionId` always. /// - If `update` is present with an object that has `sessionUpdate`, emit /// a single-key object where key = camelCase(sessionUpdate) and value = /// the `update` object minus `sessionUpdate`. /// - If `update` is absent, remove only top-level `options`. /// /// Returns None if the input is not a JSON object. fn normalize_session_event(raw_json: &str) -> Option { let mut event = AcpEvent::from_str(raw_json).ok()?; match event { AcpEvent::SessionStart(..) | AcpEvent::Error(..) | AcpEvent::Done(..) | AcpEvent::Other(..) => return None, AcpEvent::User(..) | AcpEvent::Message(..) | AcpEvent::Thought(..) | AcpEvent::ToolCall(..) | AcpEvent::ToolUpdate(..) | AcpEvent::Plan(..) | AcpEvent::AvailableCommands(..) | AcpEvent::ApprovalRequested { .. } | AcpEvent::ApprovalResponse(..) | AcpEvent::CurrentMode(..) => {} AcpEvent::RequestPermission(req) => event = AcpEvent::ToolUpdate(req.tool_call), } match event { AcpEvent::User(prompt) => { return serde_json::to_string(&serde_json::json!({"user": prompt})).ok(); } AcpEvent::Message(ref content) | AcpEvent::Thought(ref content) => { if let agent_client_protocol::ContentBlock::Text(text) = content { // Special simplification for pure text messages let key = if let AcpEvent::Message(_) = event { "assistant" } else { "thinking" }; return serde_json::to_string(&serde_json::json!({ key: text.text })).ok(); } } _ => {} } serde_json::to_string(&event).ok() } /// Read the raw JSONL content of a session pub fn read_session_raw(&self, session_id: &str) -> Result { let path = self.session_file_path(session_id); if !path.exists() { return Ok(String::new()); } fs::read_to_string(path) } /// Fork a session to create a new one with the same history pub fn fork_session(&self, old_id: &str, new_id: &str) -> Result<()> { let old_path = self.session_file_path(old_id); let new_path = self.session_file_path(new_id); if old_path.exists() { fs::copy(&old_path, &new_path)?; } else { // Create empty new file if old doesn't exist OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&new_path)?; } Ok(()) } /// Delete a session pub fn delete_session(&self, session_id: &str) -> Result<()> { let path = self.session_file_path(session_id); if path.exists() { fs::remove_file(path)?; } Ok(()) } /// Generate a resume prompt from session history pub fn generate_resume_prompt(&self, session_id: &str, current_prompt: &str) -> Result { let session_context = self.read_session_raw(session_id)?; Ok(format!( concat!( "RESUME CONTEXT FOR CONTINUING TASK\n\n", "=== EXECUTION HISTORY ===\n", "The following is the conversation history from this session:\n", "{}\n\n", "=== CURRENT REQUEST ===\n", "{}\n\n", "=== INSTRUCTIONS ===\n", "You are continuing work on the above task. The execution history shows ", "the previous conversation in this session. Please continue from where ", "the previous execution left off, taking into account all the context provided above." ), session_context, current_prompt )) } } /// Session metadata stored separately from events #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMetadata { pub session_id: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub parent_session: Option, pub tags: Vec, } ================================================ FILE: crates/executors/src/executors/amp.rs ================================================ use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore}; use crate::{ command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::ExecutionEnv, executors::{ AppendPrompt, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, claude::{ClaudeLogProcessor, HistoryStrategy}, }, logs::{stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider}, profile::ExecutorConfig, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Amp { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars( title = "Dangerously Allow All", description = "Allow all commands to be executed, even if they are not safe." )] pub dangerously_allow_all: Option, #[serde(flatten)] pub cmd: CmdOverrides, } impl Amp { fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new("npx -y @sourcegraph/amp@latest") .params(["--execute", "--stream-json"]); if self.dangerously_allow_all.unwrap_or(false) { builder = builder.extend_params(["--dangerously-allow-all"]); } apply_overrides(builder, &self.cmd) } } #[async_trait] impl StandardCodingAgentExecutor for Amp { async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let command_parts = self.build_command_builder()?.build_initial()?; let (executable_path, args) = command_parts.into_resolved().await?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(executable_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; // Feed the prompt in, then close the pipe so amp sees EOF if let Some(mut stdin) = child.inner().stdin.take() { stdin.write_all(combined_prompt.as_bytes()).await?; stdin.shutdown().await?; } Ok(child.into()) } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let builder = self.build_command_builder()?; let continue_line = builder.build_follow_up(&[ "threads".to_string(), "continue".to_string(), session_id.to_string(), ])?; let (continue_program, continue_args) = continue_line.into_resolved().await?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(continue_program); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(&continue_args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; // Feed the prompt in, then close the pipe so amp sees EOF if let Some(mut stdin) = child.inner().stdin.take() { stdin.write_all(combined_prompt.as_bytes()).await?; stdin.shutdown().await?; } Ok(child.into()) } fn normalize_logs( &self, msg_store: Arc, current_dir: &Path, ) -> Vec> { let entry_index_provider = EntryIndexProvider::start_from(&msg_store); // Process stdout logs (Amp's stream JSON output) using Claude's log processor let h1 = ClaudeLogProcessor::process_logs( msg_store.clone(), current_dir, entry_index_provider.clone(), HistoryStrategy::AmpResume, ); // Process stderr logs using the standard stderr processor let h2 = normalize_stderr_logs(msg_store, entry_index_provider); vec![h1, h2] } // MCP configuration methods fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".config").join("amp").join("settings.json")) } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::Amp, variant: None, model_id: None, agent_id: None, reasoning_id: None, permission_policy: Some(crate::model_selector::PermissionPolicy::Auto), } } } ================================================ FILE: crates/executors/src/executors/claude/client.rs ================================================ use std::sync::Arc; use tokio_util::sync::CancellationToken; use workspace_utils::approvals::{ApprovalStatus, QuestionStatus}; use super::types::PermissionMode; use crate::{ approvals::{ExecutorApprovalError, ExecutorApprovalService}, env::RepoContext, executors::{ ExecutorError, claude::{ ClaudeJson, types::{ PermissionResult, PermissionUpdate, PermissionUpdateDestination, PermissionUpdateType, }, }, codex::client::LogWriter, }, }; const EXIT_PLAN_MODE_NAME: &str = "ExitPlanMode"; const ASK_USER_QUESTION_NAME: &str = "AskUserQuestion"; pub const AUTO_APPROVE_CALLBACK_ID: &str = "AUTO_APPROVE_CALLBACK_ID"; pub const STOP_GIT_CHECK_CALLBACK_ID: &str = "STOP_GIT_CHECK_CALLBACK_ID"; // Prefix for denial messages from the user, mirrors claude code CLI behavior const TOOL_DENY_PREFIX: &str = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). To tell you how to proceed, the user said: "; /// Claude Agent client with control protocol support pub struct ClaudeAgentClient { log_writer: LogWriter, approvals: Option>, auto_approve: bool, // true when approvals is None repo_context: RepoContext, commit_reminder_prompt: String, cancel: CancellationToken, } impl ClaudeAgentClient { /// Create a new client with optional approval service pub fn new( log_writer: LogWriter, approvals: Option>, repo_context: RepoContext, commit_reminder_prompt: String, cancel: CancellationToken, ) -> Arc { let auto_approve = approvals.is_none(); Arc::new(Self { log_writer, approvals, auto_approve, repo_context, commit_reminder_prompt, cancel, }) } async fn handle_approval( &self, tool_use_id: String, tool_name: String, tool_input: serde_json::Value, ) -> Result { let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable)?; let approval_id = match approval_service.create_tool_approval(&tool_name).await { Ok(id) => id, Err(err) => { self.handle_approval_error(&tool_name, &tool_use_id, &err) .await?; return Err(err.into()); } }; let _ = self .log_writer .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalRequested { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), approval_id: approval_id.clone(), })?) .await; let status = match approval_service .wait_tool_approval(&approval_id, self.cancel.clone()) .await { Ok(s) => s, Err(err) => { self.handle_approval_error(&tool_name, &tool_use_id, &err) .await?; return Err(err.into()); } }; self.log_writer .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalResponse { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), approval_status: status.clone(), })?) .await?; match status { ApprovalStatus::Approved => { if tool_name == EXIT_PLAN_MODE_NAME { Ok(PermissionResult::Allow { updated_input: tool_input, updated_permissions: Some(vec![PermissionUpdate { update_type: PermissionUpdateType::SetMode, mode: Some(PermissionMode::BypassPermissions), destination: Some(PermissionUpdateDestination::Session), rules: None, behavior: None, directories: None, }]), }) } else { Ok(PermissionResult::Allow { updated_input: tool_input, updated_permissions: None, }) } } ApprovalStatus::Denied { reason } => Ok(PermissionResult::Deny { message: format!("{}{}", TOOL_DENY_PREFIX, reason.unwrap_or_default()), interrupt: Some(false), }), ApprovalStatus::TimedOut => Ok(PermissionResult::Deny { message: "Approval request timed out".to_string(), interrupt: Some(true), }), ApprovalStatus::Pending => Ok(PermissionResult::Deny { message: "Approval still pending (unexpected)".to_string(), interrupt: Some(false), }), } } async fn handle_question( &self, tool_use_id: String, tool_name: String, tool_input: serde_json::Value, ) -> Result { let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable)?; let question_count = tool_input .get("questions") .and_then(|q| q.as_array()) .map(|a| a.len()) .unwrap_or(1); let approval_id = match approval_service .create_question_approval(&tool_name, question_count) .await { Ok(id) => id, Err(err) => { self.handle_question_error(&tool_use_id, &tool_name, &err) .await?; return Err(err.into()); } }; let _ = self .log_writer .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalRequested { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), approval_id: approval_id.clone(), })?) .await; let status = match approval_service .wait_question_answer(&approval_id, self.cancel.clone()) .await { Ok(s) => s, Err(err) => { self.handle_question_error(&tool_use_id, &tool_name, &err) .await?; return Err(err.into()); } }; self.log_writer .log_raw(&serde_json::to_string(&ClaudeJson::QuestionResponse { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone(), question_status: status.clone(), })?) .await?; match status { QuestionStatus::Answered { answers } => { let answers_map: serde_json::Map = answers .iter() .map(|qa| { ( qa.question.clone(), serde_json::Value::String(qa.answer.join(", ")), ) }) .collect(); let mut updated = tool_input.clone(); if let Some(obj) = updated.as_object_mut() { obj.insert( "answers".to_string(), serde_json::Value::Object(answers_map), ); } Ok(PermissionResult::Allow { updated_input: updated, updated_permissions: None, }) } QuestionStatus::TimedOut => Ok(PermissionResult::Deny { message: "Question request timed out".to_string(), interrupt: Some(true), }), } } async fn handle_approval_error( &self, tool_name: &str, tool_use_id: &str, err: &ExecutorApprovalError, ) -> Result<(), ExecutorError> { if !matches!(err, ExecutorApprovalError::Cancelled) { tracing::error!( "Claude approval failed for tool={} call_id={}: {err}", tool_name, tool_use_id ); } let _ = self .log_writer .log_raw(&serde_json::to_string(&ClaudeJson::ApprovalResponse { tool_call_id: tool_use_id.to_string(), tool_name: tool_name.to_string(), approval_status: ApprovalStatus::Denied { reason: Some(format!("Approval service error: {err}")), }, })?) .await; Ok(()) } async fn handle_question_error( &self, tool_use_id: &str, tool_name: &str, err: &ExecutorApprovalError, ) -> Result<(), ExecutorError> { if !matches!(err, ExecutorApprovalError::Cancelled) { tracing::error!("Claude question failed {err}",); } let _ = self .log_writer .log_raw(&serde_json::to_string(&ClaudeJson::QuestionResponse { tool_call_id: tool_use_id.to_string(), tool_name: tool_name.to_string(), question_status: QuestionStatus::TimedOut, })?) .await; Ok(()) } pub async fn on_can_use_tool( &self, tool_name: String, input: serde_json::Value, _permission_suggestions: Option>, tool_use_id: Option, ) -> Result { if tool_name == ASK_USER_QUESTION_NAME { if let Some(latest_tool_use_id) = tool_use_id { return self .handle_question(latest_tool_use_id, tool_name, input) .await; } else { tracing::warn!("AskUserQuestion without tool_use_id, cannot route to approval"); return Ok(PermissionResult::Deny { message: "AskUserQuestion requires user interaction but no tool_use_id was provided" .to_string(), interrupt: Some(false), }); } } if self.auto_approve { Ok(PermissionResult::Allow { updated_input: input, updated_permissions: None, }) } else if let Some(latest_tool_use_id) = tool_use_id { self.handle_approval(latest_tool_use_id, tool_name, input) .await } else { // Auto approve tools with no matching tool_use_id // tool_use_id is undocumented so this may not be possible tracing::warn!( "No tool_use_id available for tool '{}', cannot request approval", tool_name ); Ok(PermissionResult::Allow { updated_input: input, updated_permissions: None, }) } } pub async fn on_hook_callback( &self, callback_id: String, input: serde_json::Value, _tool_use_id: Option, ) -> Result { // Stop hook git check - uses `decision` (approve/block) and `reason` fields if callback_id == STOP_GIT_CHECK_CALLBACK_ID { if input .get("stop_hook_active") .and_then(|v| v.as_bool()) .unwrap_or(false) { return Ok(serde_json::json!({"decision": "approve"})); } let status = self.repo_context.check_uncommitted_changes().await; return Ok(if status.is_empty() { serde_json::json!({"decision": "approve"}) } else { serde_json::json!({ "decision": "block", "reason": format!("{}\n{}", self.commit_reminder_prompt, status) }) }); } if self.auto_approve { Ok(serde_json::json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Auto-approved by SDK" } })) } else { match callback_id.as_str() { AUTO_APPROVE_CALLBACK_ID => Ok(serde_json::json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": "Approved by SDK" } })), _ => { // Hook callbacks is only used to forward approval requests to can_use_tool. // This works because `ask` decision in hook callback triggers a can_use_tool request // https://docs.claude.com/en/api/agent-sdk/permissions#permission-flow-diagram Ok(serde_json::json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "Forwarding to canusetool service" } })) } } } } pub async fn log_message(&self, line: &str) -> Result<(), ExecutorError> { self.log_writer.log_raw(line).await } } ================================================ FILE: crates/executors/src/executors/claude/protocol.rs ================================================ use std::sync::Arc; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{ChildStdin, ChildStdout}, sync::Mutex, }; use tokio_util::sync::CancellationToken; use super::types::{CLIMessage, ControlRequestType, ControlResponseMessage, ControlResponseType}; use crate::{ approvals::ExecutorApprovalError, executors::{ ExecutorError, claude::{ client::ClaudeAgentClient, types::{Message, PermissionMode, SDKControlRequest, SDKControlRequestType}, }, }, }; /// Handles bidirectional control protocol communication #[derive(Clone)] pub struct ProtocolPeer { stdin: Arc>, } impl ProtocolPeer { pub fn spawn( stdin: ChildStdin, stdout: ChildStdout, client: Arc, cancel: CancellationToken, ) -> Self { let peer = Self { stdin: Arc::new(Mutex::new(stdin)), }; let reader_peer = peer.clone(); tokio::spawn(async move { if let Err(e) = reader_peer.read_loop(stdout, client, cancel).await { tracing::error!("Protocol reader loop error: {}", e); } }); peer } async fn read_loop( &self, stdout: ChildStdout, client: Arc, cancel: CancellationToken, ) -> Result<(), ExecutorError> { let mut reader = BufReader::new(stdout); let mut buffer = String::new(); let mut interrupt_sent = false; loop { buffer.clear(); tokio::select! { biased; _ = cancel.cancelled(), if !interrupt_sent => { interrupt_sent = true; tracing::info!("Cancellation received in read_loop, sending interrupt to Claude"); if let Err(e) = self.interrupt().await { tracing::warn!("Failed to send interrupt to Claude: {e}"); } // Continue the loop to read Claude's response (it should send a result) } line_result = reader.read_line(&mut buffer) => { match line_result { Ok(0) => break, // EOF Ok(_) => { let line = buffer.trim(); if line.is_empty() { continue; } client.log_message(line).await?; // Parse and handle control messages match serde_json::from_str::(line) { Ok(CLIMessage::ControlRequest { request_id, request, }) => { self.handle_control_request(&client, request_id, request) .await; } Ok(CLIMessage::Result(_)) => { break; } _ => {} } } Err(e) => { tracing::error!("Error reading stdout: {}", e); break; } } } } } Ok(()) } async fn handle_control_request( &self, client: &Arc, request_id: String, request: ControlRequestType, ) { match request { ControlRequestType::CanUseTool { tool_name, input, permission_suggestions, blocked_paths: _, tool_use_id, } => { match client .on_can_use_tool(tool_name, input, permission_suggestions, tool_use_id) .await { Ok(result) => { if let Err(e) = self .send_hook_response(request_id, serde_json::to_value(result).unwrap()) .await { tracing::error!("Failed to send permission result: {e}"); } } Err(ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled)) => { } Err(e) => { tracing::error!("Error in on_can_use_tool: {e}"); if let Err(e2) = self.send_error(request_id, e.to_string()).await { tracing::error!("Failed to send error response: {e2}"); } } } } ControlRequestType::HookCallback { callback_id, input, tool_use_id, } => { match client .on_hook_callback(callback_id, input, tool_use_id) .await { Ok(hook_output) => { if let Err(e) = self.send_hook_response(request_id, hook_output).await { tracing::error!("Failed to send hook callback result: {e}"); } } Err(e) => { tracing::error!("Error in on_hook_callback: {e}"); if let Err(e2) = self.send_error(request_id, e.to_string()).await { tracing::error!("Failed to send error response: {e2}"); } } } } } } pub async fn send_hook_response( &self, request_id: String, hook_output: serde_json::Value, ) -> Result<(), ExecutorError> { self.send_json(&ControlResponseMessage::new(ControlResponseType::Success { request_id, response: Some(hook_output), })) .await } /// Send error response to CLI async fn send_error(&self, request_id: String, error: String) -> Result<(), ExecutorError> { self.send_json(&ControlResponseMessage::new(ControlResponseType::Error { request_id, error: Some(error), })) .await } async fn send_json(&self, message: &T) -> Result<(), ExecutorError> { let json = serde_json::to_string(message)?; let mut stdin = self.stdin.lock().await; stdin.write_all(json.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.flush().await?; Ok(()) } pub async fn send_user_message(&self, content: String) -> Result<(), ExecutorError> { let message = Message::new_user(content); self.send_json(&message).await } pub async fn initialize(&self, hooks: Option) -> Result<(), ExecutorError> { self.send_json(&SDKControlRequest::new(SDKControlRequestType::Initialize { hooks, })) .await } pub async fn interrupt(&self) -> Result<(), ExecutorError> { self.send_json(&SDKControlRequest::new(SDKControlRequestType::Interrupt {})) .await } pub async fn set_permission_mode(&self, mode: PermissionMode) -> Result<(), ExecutorError> { self.send_json(&SDKControlRequest::new( SDKControlRequestType::SetPermissionMode { mode }, )) .await } } ================================================ FILE: crates/executors/src/executors/claude/slash_commands.rs ================================================ use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, process::Stdio, sync::OnceLock, time::Duration, }; use convert_case::{Case, Casing}; use tokio::{ fs, io::{AsyncBufReadExt, BufReader}, process::Command, }; use workspace_utils::command_ext::GroupSpawnNoWindowExt; use super::{ClaudeCode, ClaudeJson, ClaudePlugin, base_command}; use crate::{ command::{CommandBuildError, CommandBuilder, apply_overrides}, env::{ExecutionEnv, RepoContext}, executors::{ExecutorError, SlashCommandDescription}, model_selector::AgentInfo, }; const SLASH_COMMANDS_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(120); impl ClaudeCode { fn extract_description(content: &str) -> Option { if !content.starts_with("---") { return None; } // Find end of frontmatter let end = content[3..].find("---")?; let frontmatter = &content[3..3 + end]; for line in frontmatter.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix("description:") { return Some(rest.trim().to_string()); } } None } fn make_key(prefix: &Option, name: &str) -> String { prefix .as_ref() .map(|p| format!("{}:{}", p, name)) .unwrap_or_else(|| name.to_string()) } async fn try_read_description(path: &Path) -> Option { match fs::read_to_string(path).await { Ok(content) => Self::extract_description(&content).or_else(|| { tracing::warn!("Failed to read frontmatter description from {:?}", path); None }), Err(e) => { tracing::error!("Failed to read file {:?}: {}", path, e); None } } } async fn scan_dir( dir: &Path, prefix: &Option, get_entry: fn(&Path) -> Option<(&str, PathBuf)>, ) -> HashMap { let mut result = HashMap::new(); if let Ok(mut entries) = fs::read_dir(dir).await { while let Ok(Some(entry)) = entries.next_entry().await { if let Some((name, desc_path)) = get_entry(&entry.path()) && let Some(desc) = Self::try_read_description(&desc_path).await { result.insert(Self::make_key(prefix, name), desc); } } } result } async fn scan_base_path(base_path: &Path, prefix: Option) -> HashMap { let mut descriptions = HashMap::new(); descriptions.extend( Self::scan_dir(&base_path.join("commands"), &prefix, |path| { path.extension() .is_some_and(|ext| ext == "md") .then(|| { let name = path.file_stem()?.to_str()?; Some((name, path.to_path_buf())) }) .flatten() }) .await, ); descriptions.extend( Self::scan_dir(&base_path.join("skills"), &prefix, |path| { path.is_dir() .then(|| { let name = path.file_name()?.to_str()?; let skill_md = path.join("SKILL.md"); skill_md.exists().then_some((name, skill_md)) }) .flatten() }) .await, ); descriptions } pub async fn discover_custom_command_descriptions( current_dir: &Path, plugins: &[ClaudePlugin], ) -> HashMap { let mut descriptions = HashMap::new(); // Project specific descriptions.extend(Self::scan_base_path(¤t_dir.join(".claude"), None).await); // Global if let Some(home) = dirs::home_dir() { descriptions.extend(Self::scan_base_path(&home.join(".claude"), None).await); } // Plugins for plugin in plugins { descriptions .extend(Self::scan_base_path(&plugin.path, Some(plugin.name.clone())).await); descriptions.extend( Self::scan_base_path(&plugin.path.join(".claude"), Some(plugin.name.clone())).await, ); } descriptions } pub(super) fn hardcoded_slash_commands() -> Vec { static KNOWN_SLASH_COMMANDS: OnceLock> = OnceLock::new(); KNOWN_SLASH_COMMANDS.get_or_init(|| { vec![ SlashCommandDescription { name: "compact".to_string(), description: Some( "Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]" .to_string(), ), }, SlashCommandDescription { name: "review".to_string(), description: Some("Review a pull request".to_string()), }, SlashCommandDescription { name: "security-review".to_string(), description: Some( "Complete a security review of the pending changes on the current branch" .to_string(), ), }, SlashCommandDescription { name: "init".to_string(), description: Some( "Initialize a new CLAUDE.md file with codebase documentation".to_string(), ), }, SlashCommandDescription { name: "pr-comments".to_string(), description: Some("Get comments from a GitHub pull request".to_string()), }, SlashCommandDescription { name: "context".to_string(), description: Some( "Visualize current context usage as a colored grid".to_string(), ), }, SlashCommandDescription { name: "cost".to_string(), description: Some( "Show the total cost and duration of the current session".to_string(), ), }, SlashCommandDescription { name: "release-notes".to_string(), description: Some("View release notes".to_string()), }, ] }).clone() } async fn build_slash_commands_discovery_command_builder( &self, ) -> Result { let mut builder = CommandBuilder::new(base_command(self.claude_code_router.unwrap_or(false))) .params(["-p"]); builder = builder.extend_params([ "--verbose", "--output-format=stream-json", "--max-turns", "1", "--", "/", ]); apply_overrides(builder, &self.cmd) } async fn discover_available_command_and_plugins( &self, current_dir: &Path, ) -> Result<(Vec, Vec, Vec), ExecutorError> { let command_builder = self .build_slash_commands_discovery_command_builder() .await?; let command_parts = command_builder.build_initial()?; let (program_path, args) = command_parts.into_resolved().await?; let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .current_dir(current_dir) .args(&args); ExecutionEnv::new(RepoContext::default(), false, String::new()) .with_profile(&self.cmd) .apply_to_command(&mut command); if self.disable_api_key.unwrap_or(false) { command.env_remove("ANTHROPIC_API_KEY"); } let mut child = command.group_spawn_no_window()?; let stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("Claude Code missing stdout")) })?; let mut lines = BufReader::new(stdout).lines(); let mut discovered: Option<(Vec, Vec, Vec)> = None; let discovery = async { while let Some(line) = lines.next_line().await.map_err(ExecutorError::Io)? { if let Ok(json) = serde_json::from_str::(&line) && let ClaudeJson::System { subtype, slash_commands, plugins, agents, .. } = &json && matches!(subtype.as_deref(), Some("init")) { discovered = Some((slash_commands.clone(), plugins.clone(), agents.clone())); break; } } Ok::<(), ExecutorError>(()) }; let res = tokio::time::timeout(SLASH_COMMANDS_DISCOVERY_TIMEOUT, discovery).await; let _ = child.kill().await; let result = match res { Ok(Ok(())) => discovered.unwrap_or_else(|| (vec![], vec![], vec![])), Ok(Err(e)) => return Err(e), Err(_) => { return Err(ExecutorError::Io(std::io::Error::other( "Timed out discovering Claude Code slash commands", ))); } }; Ok(result) } pub async fn discover_available_slash_commands( &self, current_dir: &Path, ) -> Result, ExecutorError> { let (names, plugins, _) = self .discover_available_command_and_plugins(current_dir) .await?; let descriptions = Self::discover_custom_command_descriptions(current_dir, &plugins).await; let builtin: HashSet = Self::hardcoded_slash_commands() .iter() .map(|c| c.name.clone()) .collect(); let mut seen = HashSet::new(); let names = names .into_iter() .filter(|name| !name.is_empty() && !builtin.contains(name) && seen.insert(name.clone())) .collect::>(); let commands: Vec = names .into_iter() .map(|name| SlashCommandDescription { name: name.to_string(), description: descriptions.get(&name).cloned(), }) .collect(); Ok(commands) } pub async fn discover_available_agents( &self, current_dir: &Path, ) -> Result, ExecutorError> { let (_, _, agents) = self .discover_available_command_and_plugins(current_dir) .await?; Ok(Self::map_discovered_agents(agents)) } pub async fn discover_agents_and_slash_commands_initial( &self, current_dir: &Path, ) -> Result< ( Vec, Vec, Vec, ), ExecutorError, > { let (names, plugins, agents) = self .discover_available_command_and_plugins(current_dir) .await?; let agent_options = Self::map_discovered_agents(agents); let builtin: HashSet = Self::hardcoded_slash_commands() .iter() .map(|c| c.name.clone()) .collect(); let mut seen = HashSet::new(); let slash_commands: Vec = names .into_iter() .filter(|name| !name.is_empty() && !builtin.contains(name) && seen.insert(name.clone())) .map(|name| SlashCommandDescription { name, description: None, }) .collect(); Ok((agent_options, slash_commands, plugins)) } pub async fn fill_slash_command_descriptions( current_dir: &Path, plugins: &[ClaudePlugin], slash_commands: &[SlashCommandDescription], ) -> Vec { let descriptions = Self::discover_custom_command_descriptions(current_dir, plugins).await; slash_commands .iter() .map(|cmd| SlashCommandDescription { name: cmd.name.clone(), description: descriptions .get(&cmd.name) .cloned() .or(cmd.description.clone()), }) .collect() } fn map_discovered_agents(agents: Vec) -> Vec { let mut seen = HashSet::new(); agents .into_iter() .filter(|name| name != "statusline-setup") .filter_map(|name| { let option = AgentInfo { id: name.clone(), label: Self::format_agent_label(&name), description: None, is_default: name == "general-purpose", }; if option.id.trim().is_empty() || !seen.insert(option.id.clone()) { return None; } Some(option) }) .collect() } fn format_agent_label(raw: &str) -> String { let raw = raw.trim(); if let Some((prefix, suffix)) = raw.split_once(':') { format!("{}: {}", prefix.trim(), suffix.to_case(Case::Title)) } else { raw.to_case(Case::Title) } } } ================================================ FILE: crates/executors/src/executors/claude/types.rs ================================================ //! Type definitions for Claude Code control protocol //! //! Similar to: https://github.com/ZhangHanDong/claude-code-api-rs/blob/main/claude-code-sdk-rs/src/types.rs use serde::{Deserialize, Serialize}; use serde_json::Value; /// Top-level message types from CLI stdout #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CLIMessage { ControlRequest { request_id: String, request: ControlRequestType, }, ControlResponse { response: ControlResponseType, }, ControlCancelRequest { request_id: String, }, Result(serde_json::Value), #[serde(untagged)] Other(serde_json::Value), } /// Control request from SDK to CLI (outgoing) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SDKControlRequest { #[serde(rename = "type")] message_type: String, // Always "control_request" pub request_id: String, pub request: SDKControlRequestType, } impl SDKControlRequest { pub fn new(request: SDKControlRequestType) -> Self { use uuid::Uuid; Self { message_type: "control_request".to_string(), request_id: Uuid::new_v4().to_string(), request, } } } /// Control response from SDK to CLI (outgoing) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ControlResponseMessage { #[serde(rename = "type")] message_type: String, // Always "control_response" pub response: ControlResponseType, } impl ControlResponseMessage { pub fn new(response: ControlResponseType) -> Self { Self { message_type: "control_response".to_string(), response, } } } /// Types of control requests #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "subtype", rename_all = "snake_case")] pub enum ControlRequestType { CanUseTool { tool_name: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] permission_suggestions: Option>, #[serde(skip_serializing_if = "Option::is_none")] blocked_paths: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_use_id: Option, }, HookCallback { #[serde(rename = "callback_id")] callback_id: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] tool_use_id: Option, }, } /// Result of permission check #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "behavior", rename_all = "camelCase")] pub enum PermissionResult { Allow { #[serde(rename = "updatedInput")] updated_input: Value, #[serde(skip_serializing_if = "Option::is_none", rename = "updatedPermissions")] updated_permissions: Option>, }, Deny { message: String, #[serde(skip_serializing_if = "Option::is_none")] interrupt: Option, }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PermissionUpdateType { SetMode, AddRules, RemoveRules, ClearRules, #[serde(other)] Unknown, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PermissionUpdateDestination { Session, UserSettings, ProjectSettings, LocalSettings, #[serde(other)] Unknown, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionRuleValue { pub tool_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub rule_content: Option, } /// Permission update operation #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct PermissionUpdate { #[serde(rename = "type")] pub update_type: PermissionUpdateType, #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub destination: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub behavior: Option, #[serde(skip_serializing_if = "Option::is_none")] pub directories: Option>, } /// Control response from SDK to CLI #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "subtype", rename_all = "snake_case")] pub enum ControlResponseType { Success { request_id: String, #[serde(skip_serializing_if = "Option::is_none")] response: Option, }, Error { request_id: String, #[serde(skip_serializing_if = "Option::is_none")] error: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Message { User { message: ClaudeUserMessage }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClaudeUserMessage { role: String, content: String, } impl Message { pub fn new_user(content: String) -> Self { Self::User { message: ClaudeUserMessage { role: "user".to_string(), content, }, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "subtype", rename_all = "snake_case")] pub enum SDKControlRequestType { SetPermissionMode { mode: PermissionMode, }, Initialize { #[serde(skip_serializing_if = "Option::is_none")] hooks: Option, }, Interrupt {}, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PermissionMode { Default, AcceptEdits, Plan, BypassPermissions, } impl PermissionMode { pub fn as_str(&self) -> &'static str { match self { Self::Default => "default", Self::AcceptEdits => "acceptEdits", Self::Plan => "plan", Self::BypassPermissions => "bypassPermissions", } } } impl std::fmt::Display for PermissionMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } } ================================================ FILE: crates/executors/src/executors/claude.rs ================================================ // SDK submodules pub mod client; pub mod protocol; pub mod slash_commands; pub mod types; use std::{ collections::HashMap, path::{Path, PathBuf}, process::Stdio, sync::Arc, time::Duration, }; use async_trait::async_trait; use futures::StreamExt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::process::Command; use tokio_util::sync::CancellationToken; use ts_rs::TS; use workspace_utils::{ approvals::{ApprovalStatus, QuestionStatus}, command_ext::GroupSpawnNoWindowExt, diff::create_unified_diff, log_msg::LogMsg, msg_store::MsgStore, path::make_path_relative, }; use self::{ client::{AUTO_APPROVE_CALLBACK_ID, ClaudeAgentClient, STOP_GIT_CHECK_CALLBACK_ID}, protocol::ProtocolPeer, types::{ControlRequestType, ControlResponseType, PermissionMode}, }; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, CommandParts, apply_overrides}, env::ExecutionEnv, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, codex::client::LogWriter, utils::reorder_slash_commands, }, logs::{ ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolStatus, plain_text_processor::PlainTextLogProcessor, utils::{ EntryIndexProvider, patch::{self, ConversationPatch}, shell_command_parsing::CommandCategory, }, }, model_selector::PermissionPolicy, profile::ExecutorConfig, stdout_dup::create_stdout_pipe_writer, }; const SUPPRESSED_STDERR_PATTERNS: &[&str] = &["[WARN] Fast mode requires the native binary"]; fn base_command(claude_code_router: bool) -> &'static str { if claude_code_router { "npx -y @musistudio/claude-code-router@1.0.66 code" } else { "npx -y @anthropic-ai/claude-code@2.1.62" } } fn normalize_claude_stderr_logs( msg_store: Arc, entry_index_provider: EntryIndexProvider, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stderr = msg_store.stderr_chunked_stream(); let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(|content: String| NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: strip_ansi_escapes::strip_str(&content), metadata: None, }) .time_gap(Duration::from_secs(2)) .index_provider(entry_index_provider) .transform_lines(Box::new(|lines: &mut Vec| { lines.retain(|line| { !SUPPRESSED_STDERR_PATTERNS .iter() .any(|pattern| line.contains(pattern)) }); })) .build(); while let Some(Ok(chunk)) = stderr.next().await { for patch in processor.process(chunk) { msg_store.push_patch(patch); } } }) } use derivative::Derivative; use strum_macros::{AsRefStr, EnumString}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS, JsonSchema, AsRefStr, EnumString)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ClaudeEffort { Low, Medium, High, Max, } #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct ClaudeCode { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_code_router: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub plan: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub approvals: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dangerously_skip_permissions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub disable_api_key: Option, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] approvals_service: Option>, } impl ClaudeCode { async fn build_command_builder(&self) -> Result { // If base_command_override is provided and claude_code_router is also set, log a warning if self.cmd.base_command_override.is_some() && self.claude_code_router.is_some() { tracing::warn!( "base_command_override is set, this will override the claude_code_router setting" ); } let mut builder = CommandBuilder::new(base_command(self.claude_code_router.unwrap_or(false))) .params(["-p"]); let plan = self.plan.unwrap_or(false); let approvals = self.approvals.unwrap_or(false); if plan && approvals { tracing::warn!("Both plan and approvals are enabled. Plan will take precedence."); } if plan || approvals { // Enable bypass at startup, otherwise we cannot change to it after exiting plan mode builder = builder.extend_params(["--permission-prompt-tool=stdio"]); builder = builder.extend_params([format!( "--permission-mode={}", PermissionMode::BypassPermissions )]); } else { builder = builder.extend_params(["--disallowedTools=AskUserQuestion"]); } if self.dangerously_skip_permissions.unwrap_or(false) { builder = builder.extend_params(["--dangerously-skip-permissions"]); } if let Some(model) = &self.model { builder = builder.extend_params(["--model", model]); } if let Some(effort) = &self.effort { builder = builder.extend_params(["--effort", effort.as_ref()]); } if let Some(agent) = &self.agent { builder = builder.extend_params(["--agent", agent]); } builder = builder.extend_params([ "--verbose", "--output-format=stream-json", "--input-format=stream-json", "--include-partial-messages", "--replay-user-messages", ]); apply_overrides(builder, &self.cmd) } pub fn permission_mode(&self) -> PermissionMode { if self.plan.unwrap_or(false) { PermissionMode::Plan } else if self.approvals.unwrap_or(false) { PermissionMode::Default } else { PermissionMode::BypassPermissions } } pub fn get_hooks(&self, commit_reminder: bool) -> Option { let mut hooks = serde_json::Map::new(); if commit_reminder { hooks.insert( "Stop".to_string(), serde_json::json!([{ "hookCallbackIds": [STOP_GIT_CHECK_CALLBACK_ID] }]), ); } // Add PreToolUse hooks based on plan/approvals settings if self.plan.unwrap_or(false) { hooks.insert( "PreToolUse".to_string(), serde_json::json!([ { "matcher": "^(ExitPlanMode|AskUserQuestion)$", "hookCallbackIds": ["tool_approval"], }, { "matcher": "^(?!(ExitPlanMode|AskUserQuestion)$).*", "hookCallbackIds": [AUTO_APPROVE_CALLBACK_ID], } ]), ); } else if self.approvals.unwrap_or(false) { hooks.insert( "PreToolUse".to_string(), serde_json::json!([ { "matcher": "^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*", "hookCallbackIds": ["tool_approval"], } ]), ); } else { hooks.insert( "PreToolUse".to_string(), serde_json::json!([ { "matcher": "^AskUserQuestion$", "hookCallbackIds": ["tool_approval"], } ]), ); } Some(serde_json::Value::Object(hooks)) } fn compute_cmd_key(&self) -> String { serde_json::to_string(&self.cmd).unwrap_or_default() } } fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions { use crate::{ executor_discovery::ExecutorDiscoveredOptions, model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption}, }; let effort_options = ReasoningOption::from_names(["low", "medium", "high", "max"].map(String::from)); let supports_effort = |id: &str| -> bool { id.contains("opus") || id.contains("sonnet") }; ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { providers: vec![], models: [ ("opus", "Opus"), ("opus[1m]", "Opus (1M context)"), ("sonnet", "Sonnet"), ("haiku", "Haiku"), ] .into_iter() .map(|(id, name)| ModelInfo { id: id.to_string(), name: name.to_string(), provider_id: None, reasoning_options: if supports_effort(id) { effort_options.clone() } else { vec![] }, }) .collect(), default_model: Some("opus".to_string()), agents: vec![], permissions: vec![ PermissionPolicy::Auto, PermissionPolicy::Supervised, PermissionPolicy::Plan, ], }, slash_commands: ClaudeCode::hardcoded_slash_commands(), loading_models: false, loading_agents: false, loading_slash_commands: false, error: None, } } #[async_trait] impl StandardCodingAgentExecutor for ClaudeCode { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(agent) = &executor_config.agent_id { self.agent = Some(agent.clone()); } if let Some(reasoning_id) = &executor_config.reasoning_id { self.effort = reasoning_id.parse().ok(); } if let Some(permission_policy) = executor_config.permission_policy.clone() { match permission_policy { PermissionPolicy::Plan => { self.plan = Some(true); self.approvals = Some(false); } PermissionPolicy::Supervised => { self.plan = Some(false); self.approvals = Some(true); } PermissionPolicy::Auto => { self.plan = Some(false); self.approvals = Some(false); } } } } fn use_approvals(&mut self, approvals: Arc) { self.approvals_service = Some(approvals); } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let command_builder = self.build_command_builder().await?; let command_parts = command_builder.build_initial()?; self.spawn_internal(current_dir, prompt, command_parts, env) .await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let command_builder = self.build_command_builder().await?; let mut args = vec!["--resume".to_string(), session_id.to_string()]; // --resume-session-at truncates Claude's conversation history to the specified // message and continues from there. if let Some(uuid) = reset_to_message_id { args.push("--resume-session-at".to_string()); args.push(uuid.to_string()); } let command_parts = command_builder.build_follow_up(&args)?; self.spawn_internal(current_dir, prompt, command_parts, env) .await } fn normalize_logs( &self, msg_store: Arc, current_dir: &Path, ) -> Vec> { let entry_index_provider = EntryIndexProvider::start_from(&msg_store); // Process stdout logs (Claude's JSON output) let h1 = ClaudeLogProcessor::process_logs( msg_store.clone(), current_dir, entry_index_provider.clone(), HistoryStrategy::Default, ); // Process stderr logs let h2 = normalize_claude_stderr_logs(msg_store, entry_index_provider); vec![h1, h2] } async fn discover_options( &self, workdir: Option<&Path>, repo_path: Option<&Path>, ) -> Result, ExecutorError> { use crate::{ executor_discovery::ExecutorConfigCacheKey, executors::utils::executor_options_cache, }; let cache = executor_options_cache(); let cmd_key = self.compute_cmd_key(); let base_executor = BaseCodingAgent::ClaudeCode; let (target_path, initial_options) = if let Some(wd) = workdir { let wd_buf = wd.to_path_buf(); let target_key = ExecutorConfigCacheKey::new(Some(&wd_buf), cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&target_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } let provisional = repo_path .and_then(|rp| { let rp_buf = rp.to_path_buf(); let repo_key = ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor); cache.get(&repo_key) }) .or_else(|| { let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); cache.get(&global_key) }); ( Some(wd.to_path_buf()), provisional .map(|p| { let mut opts = p.as_ref().clone(); opts.loading_models = false; opts.loading_agents = true; opts.loading_slash_commands = true; opts }) .unwrap_or_else(|| { let mut opts = default_discovered_options(); opts.loading_models = false; opts.loading_agents = true; opts.loading_slash_commands = true; opts }), ) } else if let Some(rp) = repo_path { let rp_buf = rp.to_path_buf(); let target_key = ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&target_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); let provisional = cache.get(&global_key); ( Some(rp.to_path_buf()), provisional .map(|p| { let mut opts = p.as_ref().clone(); opts.loading_models = false; opts.loading_agents = true; opts.loading_slash_commands = true; opts }) .unwrap_or_else(|| { let mut opts = default_discovered_options(); opts.loading_models = false; opts.loading_agents = true; opts.loading_slash_commands = true; opts }), ) } else { let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&global_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } let mut opts = default_discovered_options(); opts.loading_models = false; opts.loading_agents = true; opts.loading_slash_commands = true; (None, opts) }; let initial_patch = patch::executor_discovered_options(initial_options); let this = self.clone(); let cmd_key_for_discovery = cmd_key.clone(); let discovery_stream = async_stream::stream! { let discovery_path = target_path.as_deref().unwrap_or(Path::new(".")).to_path_buf(); let mut final_options = default_discovered_options(); match this.discover_agents_and_slash_commands_initial(&discovery_path).await { Ok((mut agent_options, slash_commands_initial, plugins)) => { let default_agents = [ "Bash", "general-purpose", "statusline-setup", "Explore", "Plan", ]; agent_options.retain(|a| !default_agents.contains(&a.id.as_str())); final_options.model_selector.agents = agent_options.clone(); yield patch::update_agents(agent_options); yield patch::agents_loaded(); let defaults = Self::hardcoded_slash_commands(); let slash_commands = reorder_slash_commands( [slash_commands_initial, defaults].concat() ); final_options.slash_commands = slash_commands.clone(); yield patch::update_slash_commands(slash_commands); let slash_commands_with_descriptions = Self::fill_slash_command_descriptions( &discovery_path, &plugins, &final_options.slash_commands, ).await; final_options.slash_commands = slash_commands_with_descriptions; yield patch::update_slash_commands(final_options.slash_commands.clone()); yield patch::slash_commands_loaded(); let cache = executor_options_cache(); if let Some(path) = &target_path { let target_cache_key = ExecutorConfigCacheKey::new( Some(path), cmd_key_for_discovery.clone(), BaseCodingAgent::ClaudeCode, ); cache.put(target_cache_key, final_options.clone()); } let global_cache_key = ExecutorConfigCacheKey::new( None, cmd_key_for_discovery, BaseCodingAgent::ClaudeCode, ); cache.put(global_cache_key, final_options); } Err(e) => { tracing::warn!("Failed to discover Claude Code options: {}", e); yield patch::discovery_error(e.to_string()); } } }; Ok(Box::pin( futures::stream::once(async move { initial_patch }).chain(discovery_stream), )) } fn get_preset_options(&self) -> ExecutorConfig { use crate::model_selector::*; let permission_policy = if self.plan.unwrap_or(false) { PermissionPolicy::Plan } else if self.dangerously_skip_permissions.unwrap_or(false) { PermissionPolicy::Auto } else if self.approvals.unwrap_or(false) { PermissionPolicy::Supervised } else { PermissionPolicy::Auto }; ExecutorConfig { executor: BaseCodingAgent::ClaudeCode, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: self.effort.as_ref().map(|e| e.as_ref().to_owned()), permission_policy: Some(permission_policy), } } // MCP configuration methods fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".claude.json")) } fn get_availability_info(&self) -> AvailabilityInfo { let auth_file_path = dirs::home_dir().map(|home| home.join(".claude.json")); if let Some(path) = auth_file_path && let Some(timestamp) = std::fs::metadata(&path) .ok() .and_then(|m| m.modified().ok()) .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) { return AvailabilityInfo::LoginDetected { last_auth_timestamp: timestamp, }; } AvailabilityInfo::NotFound } } impl ClaudeCode { async fn spawn_internal( &self, current_dir: &Path, prompt: &str, command_parts: CommandParts, env: &ExecutionEnv, ) -> Result { let (program_path, args) = command_parts.into_resolved().await?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); // Remove ANTHROPIC_API_KEY if disable_api_key is enabled if self.disable_api_key.unwrap_or(false) { command.env_remove("ANTHROPIC_API_KEY"); tracing::info!("ANTHROPIC_API_KEY removed from environment"); } let mut child = command.group_spawn_no_window()?; let child_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("Claude Code missing stdout")) })?; let child_stdin = child.inner().stdin.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("Claude Code missing stdin")) })?; let new_stdout = create_stdout_pipe_writer(&mut child)?; let permission_mode = self.permission_mode(); let hooks = self.get_hooks(env.commit_reminder); // Create cancellation token for graceful shutdown let cancel = CancellationToken::new(); // Spawn task to handle the SDK client with control protocol let prompt_clone = combined_prompt.clone(); let approvals_clone = self.approvals_service.clone(); let repo_context = env.repo_context.clone(); let commit_reminder_prompt = env.commit_reminder_prompt.clone(); let cancel_for_task = cancel.clone(); tokio::spawn(async move { let log_writer = LogWriter::new(new_stdout); let client = ClaudeAgentClient::new( log_writer.clone(), approvals_clone, repo_context, commit_reminder_prompt, cancel_for_task.clone(), ); let protocol_peer = ProtocolPeer::spawn(child_stdin, child_stdout, client.clone(), cancel_for_task); // Initialize control protocol if let Err(e) = protocol_peer.initialize(hooks).await { tracing::error!("Failed to initialize control protocol: {e}"); let _ = log_writer .log_raw(&format!("Error: Failed to initialize - {e}")) .await; return; } if let Err(e) = protocol_peer.set_permission_mode(permission_mode).await { tracing::warn!("Failed to set permission mode to {permission_mode}: {e}"); } // Send user message if let Err(e) = protocol_peer.send_user_message(prompt_clone).await { tracing::error!("Failed to send prompt: {e}"); let _ = log_writer .log_raw(&format!("Error: Failed to send prompt - {e}")) .await; } }); Ok(SpawnedChild { child, exit_signal: None, cancel: Some(cancel), }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HistoryStrategy { // Claude-code format Default, // Amp threads format which includes logs from previous executions AmpResume, } /// Default context window for models (used until we get actual value from result) const DEFAULT_CLAUDE_CONTEXT_WINDOW: u32 = 200_000; /// Handles log processing and interpretation for Claude executor pub struct ClaudeLogProcessor { model_name: Option, // Map tool_use_id -> structured info for follow-up ToolResult replacement tool_map: HashMap, // Strategy controlling how to handle history and user messages strategy: HistoryStrategy, streaming_messages: HashMap, streaming_message_id: Option, last_assistant_message: Option, // Main model name (excluding subagents). Only used internally for context window tracking. main_model_name: Option, main_model_context_window: u32, context_tokens_used: u32, } impl ClaudeLogProcessor { #[cfg(test)] fn new() -> Self { Self::new_with_strategy(HistoryStrategy::Default) } fn new_with_strategy(strategy: HistoryStrategy) -> Self { Self { model_name: None, main_model_name: None, tool_map: HashMap::new(), strategy, streaming_messages: HashMap::new(), streaming_message_id: None, last_assistant_message: None, main_model_context_window: DEFAULT_CLAUDE_CONTEXT_WINDOW, context_tokens_used: 0, } } /// Process raw logs and convert them to normalized entries with patches pub fn process_logs( msg_store: Arc, current_dir: &Path, entry_index_provider: EntryIndexProvider, strategy: HistoryStrategy, ) -> tokio::task::JoinHandle<()> { let current_dir_clone = current_dir.to_owned(); tokio::spawn(async move { let mut stream = msg_store.history_plus_stream(); let mut buffer = String::new(); let worktree_path = current_dir_clone.to_string_lossy().to_string(); let mut session_id_extracted = false; let mut processor = Self::new_with_strategy(strategy); // Track pending assistant UUID - only committed when we see a Result message let mut pending_assistant_uuid: Option = None; while let Some(Ok(msg)) = stream.next().await { let chunk = match msg { LogMsg::Stdout(x) => x, LogMsg::JsonPatch(_) | LogMsg::SessionId(_) | LogMsg::MessageId(_) | LogMsg::Stderr(_) | LogMsg::Ready => continue, LogMsg::Finished => break, }; buffer.push_str(&chunk); // Process complete JSON lines for line in buffer .split_inclusive('\n') .filter(|l| l.ends_with('\n')) .map(str::to_owned) .collect::>() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } // Filter out claude-code-router service messages if trimmed.starts_with("Service not running, starting service") || trimmed .contains("claude code router service has been successfully stopped") { continue; } match serde_json::from_str::(trimmed) { Ok(claude_json) => { if !session_id_extracted && let Some(session_id) = Self::extract_session_id(&claude_json) { msg_store.push_session_id(session_id); session_id_extracted = true; } // Track message UUIDs for --resume-session-at: // - User messages: always valid, push immediately and clear pending // - Assistant messages: may have incomplete tool calls, store as pending // - Result messages: confirms assistant turn is complete, commit pending match &claude_json { ClaudeJson::User { uuid, .. } => { pending_assistant_uuid = None; if let Some(uuid) = uuid { msg_store.push_message_id(uuid.clone()); } } ClaudeJson::Assistant { uuid, .. } => { pending_assistant_uuid = uuid.clone(); } ClaudeJson::Result { .. } => { if let Some(uuid) = pending_assistant_uuid.take() { msg_store.push_message_id(uuid); } } _ => {} } let patches = processor.normalize_entries( &claude_json, &worktree_path, &entry_index_provider, ); for patch in patches { msg_store.push_patch(patch); } } Err(_) => { // Handle non-JSON output as raw system message if !trimmed.is_empty() { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: trimmed.to_string(), metadata: None, }; let patch_id = entry_index_provider.next(); let patch = ConversationPatch::add_normalized_entry(patch_id, entry); msg_store.push_patch(patch); } } } } // Keep the partial line in the buffer buffer = buffer.rsplit('\n').next().unwrap_or("").to_owned(); } // Handle any remaining content in buffer if !buffer.trim().is_empty() { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: buffer.trim().to_string(), metadata: None, }; let patch_id = entry_index_provider.next(); let patch = ConversationPatch::add_normalized_entry(patch_id, entry); msg_store.push_patch(patch); } }) } /// Extract session ID from Claude JSON fn extract_session_id(claude_json: &ClaudeJson) -> Option { match claude_json { ClaudeJson::System { .. } => None, // session might not have been initialized yet ClaudeJson::Assistant { session_id, .. } => session_id.clone(), ClaudeJson::User { session_id, .. } => session_id.clone(), ClaudeJson::ToolUse { session_id, .. } => session_id.clone(), ClaudeJson::ToolResult { session_id, .. } => session_id.clone(), ClaudeJson::Result { session_id, .. } => session_id.clone(), ClaudeJson::StreamEvent { .. } => None, // session might not have been initialized yet ClaudeJson::ApprovalRequested { .. } => None, ClaudeJson::ApprovalResponse { .. } => None, ClaudeJson::QuestionResponse { .. } => None, ClaudeJson::ControlRequest { .. } => None, ClaudeJson::ControlResponse { .. } => None, ClaudeJson::ControlCancelRequest { .. } => None, ClaudeJson::RateLimitEvent { session_id, .. } => session_id.clone(), ClaudeJson::Unknown { .. } => None, } } /// Generate warning entry if API key source is ANTHROPIC_API_KEY fn warn_if_unmanaged_key(src: &Option) -> Option { match src.as_deref() { Some("ANTHROPIC_API_KEY") => { tracing::warn!( "ANTHROPIC_API_KEY env variable detected, your Anthropic subscription is not being used" ); Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: "Claude Code + ANTHROPIC_API_KEY detected. Usage will be billed via Anthropic pay-as-you-go instead of your Claude subscription. If this is unintended, please select the `disable_api_key` checkbox in the conding-agent-configurations settings page.".to_string(), metadata: None, }) } _ => None, } } /// Normalize Claude tool_result content to either Markdown string or parsed JSON. /// - If content is a string that parses as JSON, return Json with parsed value. /// - If content is a string (non-JSON), return Markdown with the raw string. /// - If content is an array of { text: string }, join texts as Markdown. /// - Otherwise return Json with the original value. fn normalize_claude_tool_result_value( content: &serde_json::Value, ) -> (crate::logs::ToolResultValueType, serde_json::Value) { if let Some(s) = content.as_str() { if let Ok(parsed) = serde_json::from_str::(s) { return (crate::logs::ToolResultValueType::Json, parsed); } return ( crate::logs::ToolResultValueType::Markdown, serde_json::Value::String(s.to_string()), ); } if let Ok(items) = serde_json::from_value::>(content.clone()) && !items.is_empty() { let joined = items .into_iter() .map(|i| i.text) .collect::>() .join("\n\n"); if let Ok(parsed) = serde_json::from_str::(&joined) { return (crate::logs::ToolResultValueType::Json, parsed); } return ( crate::logs::ToolResultValueType::Markdown, serde_json::Value::String(joined), ); } (crate::logs::ToolResultValueType::Json, content.clone()) } fn build_tool_use_entry( tool_data: &ClaudeToolData, worktree_path: &str, status: ToolStatus, ) -> (NormalizedEntry, String, String) { let tool_name = tool_data.get_name().to_string(); let action_type = Self::extract_action_type(tool_data, worktree_path); let content = Self::generate_concise_content(tool_data, &action_type, worktree_path); let entry = Self::tool_use_entry(tool_name.clone(), action_type, status, content.clone()); (entry, tool_name, content) } fn tool_use_entry( tool_name: String, action_type: ActionType, status: ToolStatus, content: String, ) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name, action_type, status, }, content, metadata: None, } } fn replace_tool_entry_status( &mut self, tool_call_id: &str, status: ToolStatus, worktree_path: &str, patches: &mut Vec, ) { if let Some(info) = self.tool_map.get(tool_call_id).cloned() { let action_type = Self::extract_action_type(&info.tool_data, worktree_path); let entry = Self::tool_use_entry( info.tool_name.clone(), action_type, status, info.content.clone(), ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } } /// Convert Claude content item to normalized entry fn content_item_to_normalized_entry( content_item: &ClaudeContentItem, role: &str, worktree_path: &str, last_assistant_message: &mut Option, ) -> Option { match content_item { ClaudeContentItem::Text { text } => { let entry_type = match role { "assistant" => NormalizedEntryType::AssistantMessage, _ => return None, }; *last_assistant_message = Some(text.clone()); Some(NormalizedEntry { timestamp: None, entry_type, content: text.clone(), metadata: Some( serde_json::to_value(content_item).unwrap_or(serde_json::Value::Null), ), }) } ClaudeContentItem::Thinking { thinking } => Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::Thinking, content: thinking.clone(), metadata: Some( serde_json::to_value(content_item).unwrap_or(serde_json::Value::Null), ), }), ClaudeContentItem::ToolUse { tool_data, id: _ } => { let (entry, _, _) = Self::build_tool_use_entry(tool_data, worktree_path, ToolStatus::Created); Some(entry) } ClaudeContentItem::ToolResult { .. } => { // TODO: Add proper ToolResult support to NormalizedEntry when the type system supports it None } } } /// Extract action type from structured tool data fn extract_action_type(tool_data: &ClaudeToolData, worktree_path: &str) -> ActionType { match tool_data { ClaudeToolData::Read { file_path } => ActionType::FileRead { path: make_path_relative(file_path, worktree_path), }, ClaudeToolData::Edit { file_path, old_string, new_string, } => { let changes = if old_string.is_some() || new_string.is_some() { vec![FileChange::Edit { unified_diff: create_unified_diff( file_path, &old_string.clone().unwrap_or_default(), &new_string.clone().unwrap_or_default(), ), has_line_numbers: false, }] } else { vec![] }; ActionType::FileEdit { path: make_path_relative(file_path, worktree_path), changes, } } ClaudeToolData::MultiEdit { file_path, edits } => { let changes: Vec = edits .iter() .filter(|edit| edit.old_string.is_some() || edit.new_string.is_some()) .map(|edit| FileChange::Edit { unified_diff: create_unified_diff( file_path, &edit.old_string.clone().unwrap_or_default(), &edit.new_string.clone().unwrap_or_default(), ), has_line_numbers: false, }) .collect(); ActionType::FileEdit { path: make_path_relative(file_path, worktree_path), changes, } } ClaudeToolData::Write { file_path, content } => { let diffs = vec![FileChange::Write { content: content.clone(), }]; ActionType::FileEdit { path: make_path_relative(file_path, worktree_path), changes: diffs, } } ClaudeToolData::Bash { command, .. } => ActionType::CommandRun { command: command.clone(), result: None, category: CommandCategory::from_command(command), }, ClaudeToolData::Grep { pattern, .. } => ActionType::Search { query: pattern.clone(), }, ClaudeToolData::WebFetch { url, .. } => ActionType::WebFetch { url: url.clone() }, ClaudeToolData::WebSearch { query, .. } => ActionType::WebFetch { url: query.clone() }, ClaudeToolData::Task { description, prompt, subagent_type, } => { let task_description = if let Some(desc) = description { desc.clone() } else { prompt.clone().unwrap_or_default() }; ActionType::TaskCreate { description: task_description, subagent_type: subagent_type.clone(), result: None, } } ClaudeToolData::ExitPlanMode { plan } => { ActionType::PlanPresentation { plan: plan.clone() } } ClaudeToolData::NotebookEdit { .. } => ActionType::Tool { tool_name: "NotebookEdit".to_string(), arguments: Some(serde_json::to_value(tool_data).unwrap_or(serde_json::Value::Null)), result: None, }, ClaudeToolData::TodoWrite { todos } => ActionType::TodoManagement { todos: todos .iter() .map(|t| TodoItem { content: t.content.clone(), status: t.status.clone(), priority: t.priority.clone(), }) .collect(), operation: "write".to_string(), }, ClaudeToolData::TodoRead { .. } => ActionType::TodoManagement { todos: vec![], operation: "read".to_string(), }, ClaudeToolData::Glob { pattern, .. } => ActionType::Search { query: pattern.clone(), }, ClaudeToolData::LS { .. } => ActionType::Other { description: "List directory".to_string(), }, ClaudeToolData::Oracle { .. } => ActionType::Other { description: "Oracle".to_string(), }, ClaudeToolData::Mermaid { .. } => ActionType::Other { description: "Mermaid diagram".to_string(), }, ClaudeToolData::CodebaseSearchAgent { .. } => ActionType::Other { description: "Codebase search".to_string(), }, ClaudeToolData::UndoEdit { .. } => ActionType::Other { description: "Undo edit".to_string(), }, ClaudeToolData::AskUserQuestion { questions } => ActionType::AskUserQuestion { questions: questions .iter() .map(|q| AskUserQuestionItem { question: q.question.clone(), header: q.header.clone(), options: q .options .iter() .map(|o| AskUserQuestionOption { label: o.label.clone(), description: o.description.clone(), }) .collect(), multi_select: q.multi_select, }) .collect(), }, ClaudeToolData::Unknown { .. } => { // Surface MCP tools as generic Tool with args let name = tool_data.get_name(); if name.starts_with("mcp__") { let parts: Vec<&str> = name.split("__").collect(); let label = if parts.len() >= 3 { format!("mcp:{}:{}", parts[1], parts[2]) } else { name.to_string() }; // Extract `input` if present by serializing then deserializing to a tiny struct let args = serde_json::to_value(tool_data) .ok() .and_then(|v| serde_json::from_value::(v).ok()) .map(|w| w.input) .unwrap_or(serde_json::Value::Null); ActionType::Tool { tool_name: label, arguments: Some(args), result: None, } } else { ActionType::Other { description: format!("Tool: {}", tool_data.get_name()), } } } } } /// Convert Claude JSON to normalized patches fn normalize_entries( &mut self, claude_json: &ClaudeJson, worktree_path: &str, entry_index_provider: &EntryIndexProvider, ) -> Vec { let mut patches = Vec::new(); match claude_json { ClaudeJson::System { subtype, api_key_source, model, status, tool_use_id, description, task_type, prompt, summary, .. } => { // emit billing warning if required if let Some(warning) = Self::warn_if_unmanaged_key(api_key_source) { let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, warning)); } // keep the existing behaviour for the normal system message match subtype.as_deref() { Some("init") => { if self.main_model_name.is_none() { // this name matches the model names in the usage report in the result message if let Some(model) = model { self.main_model_name = Some(model.clone()); if model.contains("[1m]") { self.main_model_context_window = 1_000_000; } } } // Skip system init messages because it doesn't contain the actual model that will be used in assistant messages in case of claude-code-router. // We'll send system initialized message with first assistant message that has a model field. } Some("status") => { if let Some(status) = status { patches.push(add_system_message(status.clone(), entry_index_provider)); } } Some("compact_boundary") => {} Some("task_started") => { if let Some(tool_use_id) = tool_use_id && !self.tool_map.contains_key(tool_use_id) { let desc = description.clone().unwrap_or_else(|| "Task".to_string()); let subagent_type = task_type.clone(); let entry = Self::tool_use_entry( "Task".to_string(), ActionType::TaskCreate { description: desc.clone(), subagent_type: subagent_type.clone(), result: None, }, ToolStatus::Created, desc.clone(), ); let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); self.tool_map.insert( tool_use_id.clone(), ClaudeToolCallInfo { entry_index: idx, tool_name: "Task".to_string(), tool_data: ClaudeToolData::Task { subagent_type, description: description.clone(), prompt: prompt.clone(), }, content: desc, }, ); } } Some("task_progress") => { if let (Some(tool_use_id), Some(desc)) = (tool_use_id, description) && let Some(info) = self.tool_map.get(tool_use_id).cloned() { let subagent_type = if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data { subagent_type.clone() } else { None }; let entry = Self::tool_use_entry( info.tool_name.clone(), ActionType::TaskCreate { description: info.content.clone(), subagent_type, result: None, }, ToolStatus::Created, desc.clone(), ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } } Some("task_notification") => { if let Some(tool_use_id) = tool_use_id && let Some(info) = self.tool_map.get(tool_use_id).cloned() { let task_status = match status.as_deref() { Some("failed") | Some("error") => ToolStatus::Failed, _ => ToolStatus::Success, }; let subagent_type = if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data { subagent_type.clone() } else { None }; let desc = summary .clone() .or(description.clone()) .unwrap_or_else(|| info.content.clone()); let entry = Self::tool_use_entry( info.tool_name.clone(), ActionType::TaskCreate { description: desc.clone(), subagent_type, result: None, }, task_status, desc, ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } } Some(subtype) => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("System: {subtype}"), metadata: Some( serde_json::to_value(claude_json) .unwrap_or(serde_json::Value::Null), ), }; let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } None => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: "System message".to_string(), metadata: Some( serde_json::to_value(claude_json) .unwrap_or(serde_json::Value::Null), ), }; let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } } } ClaudeJson::Assistant { message, .. } => { if let Some(patch) = extract_model_name(self, message, entry_index_provider) { patches.push(patch); } let mut streaming_message_state = message .id .as_ref() .and_then(|id| self.streaming_messages.remove(id)); for (content_index, item) in message.content.items().enumerate() { let entry_index = streaming_message_state .as_mut() .and_then(|state| state.content_entry_index(content_index)); match item { ClaudeContentItem::ToolUse { id, tool_data } => { let (entry, tool_name, content_text) = Self::build_tool_use_entry( tool_data, worktree_path, ToolStatus::Created, ); let existing_idx = entry_index .or_else(|| self.tool_map.get(id).map(|info| info.entry_index)); let is_new = existing_idx.is_none(); let id_num = existing_idx.unwrap_or_else(|| entry_index_provider.next()); self.tool_map.insert( id.clone(), ClaudeToolCallInfo { entry_index: id_num, tool_name: tool_name.clone(), tool_data: tool_data.clone(), content: content_text, }, ); let patch = if is_new { ConversationPatch::add_normalized_entry(id_num, entry) } else { ConversationPatch::replace(id_num, entry) }; patches.push(patch); } ClaudeContentItem::Text { .. } | ClaudeContentItem::Thinking { .. } => { if let Some(entry) = Self::content_item_to_normalized_entry( item, &message.role, worktree_path, &mut self.last_assistant_message, ) { let is_new = entry_index.is_none(); let idx = entry_index.unwrap_or_else(|| entry_index_provider.next()); let patch = if is_new { ConversationPatch::add_normalized_entry(idx, entry) } else { ConversationPatch::replace(idx, entry) }; patches.push(patch); } } ClaudeContentItem::ToolResult { .. } => {} } } } ClaudeJson::User { message, is_synthetic, is_replay, .. } => { // Skip replay messages entirely - they're historical context from resumed sessions if *is_replay { return patches; } if matches!(self.strategy, HistoryStrategy::AmpResume) && message .content .items() .any(|c| matches!(c, ClaudeContentItem::Text { .. })) { let cur = entry_index_provider.current(); if cur > 0 { for _ in 0..cur { patches.push(ConversationPatch::remove_diff(0.to_string())); } entry_index_provider.reset(); self.tool_map.clear(); } for item in message.content.items() { if let ClaudeContentItem::Text { text } = item { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserMessage, content: text.clone(), metadata: Some( serde_json::to_value(item).unwrap_or(serde_json::Value::Null), ), }; let id = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(id, entry)); } } } if *is_synthetic { for item in message.content.items() { if let ClaudeContentItem::Text { text } = item { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: text.clone(), metadata: None, }; let id = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(id, entry)); } } } if let Some(mut text) = message.content.as_text().cloned() { if text.starts_with("") && text.ends_with("") { text = text .trim_start_matches("") .trim_end_matches("") .to_string(); } patches.push(add_system_message(text.clone(), entry_index_provider)); } for item in message.content.items() { if let ClaudeContentItem::ToolResult { tool_use_id, content, is_error, } = item && let Some(info) = self.tool_map.get(tool_use_id).cloned() { let is_command = matches!(info.tool_data, ClaudeToolData::Bash { .. }); let _display_tool_name = if is_command { info.tool_name.clone() } else { let raw_name = info.tool_data.get_name().to_string(); if raw_name.starts_with("mcp__") { let parts: Vec<&str> = raw_name.split("__").collect(); if parts.len() >= 3 { format!("mcp:{}:{}", parts[1], parts[2]) } else { raw_name } } else { raw_name } }; if is_command { let content_str = if let Some(s) = content.as_str() { s.to_string() } else { content.to_string() }; let result = if let Ok(result) = serde_json::from_str::(&content_str) { Some(crate::logs::CommandRunResult { exit_status: Some(crate::logs::CommandExitStatus::ExitCode { code: result.exit_code, }), output: Some(result.output), }) } else { Some(crate::logs::CommandRunResult { exit_status: (*is_error).map(|is_error| { crate::logs::CommandExitStatus::Success { success: !is_error, } }), output: Some(content_str), }) }; let status = if is_error.unwrap_or(false) { ToolStatus::Failed } else { ToolStatus::Success }; let entry = Self::tool_use_entry( info.tool_name.clone(), ActionType::CommandRun { command: info.content.clone(), result, category: CommandCategory::from_command(&info.content), }, status, info.content.clone(), ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } else if matches!(info.tool_data, ClaudeToolData::Task { .. }) { // Handle Task tool results - capture subagent output let (res_type, res_value) = Self::normalize_claude_tool_result_value(content); let status = if is_error.unwrap_or(false) { ToolStatus::Failed } else { ToolStatus::Success }; // Extract subagent_type from the original tool_data let subagent_type = if let ClaudeToolData::Task { subagent_type, .. } = &info.tool_data { subagent_type.clone() } else { None }; let entry = Self::tool_use_entry( info.tool_name.clone(), ActionType::TaskCreate { description: info.content.clone(), subagent_type, result: Some(crate::logs::ToolResult { r#type: res_type, value: res_value, }), }, status, info.content.clone(), ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } else if matches!( info.tool_data, ClaudeToolData::Unknown { .. } | ClaudeToolData::Oracle { .. } | ClaudeToolData::Mermaid { .. } | ClaudeToolData::CodebaseSearchAgent { .. } | ClaudeToolData::NotebookEdit { .. } ) { let (res_type, res_value) = Self::normalize_claude_tool_result_value(content); let args_to_show = serde_json::to_value(&info.tool_data) .ok() .and_then(|v| serde_json::from_value::(v).ok()) .map(|w| w.input) .unwrap_or(serde_json::Value::Null); let tool_name = info.tool_data.get_name().to_string(); let is_mcp = tool_name.starts_with("mcp__"); let label = if is_mcp { let parts: Vec<&str> = tool_name.split("__").collect(); if parts.len() >= 3 { format!("mcp:{}:{}", parts[1], parts[2]) } else { tool_name.clone() } } else { tool_name.clone() }; let status = if is_error.unwrap_or(false) { ToolStatus::Failed } else { ToolStatus::Success }; let entry = Self::tool_use_entry( label.clone(), ActionType::Tool { tool_name: label, arguments: Some(args_to_show), result: Some(crate::logs::ToolResult { r#type: res_type, value: res_value, }), }, status, info.content.clone(), ); patches.push(ConversationPatch::replace(info.entry_index, entry)); } // Note: With control protocol, denials are handled via protocol messages // rather than error content parsing } } } ClaudeJson::ToolUse { tool_data, id, .. } => { let (entry, tool_name_value, content_text) = Self::build_tool_use_entry(tool_data, worktree_path, ToolStatus::Created); let existing = self.tool_map.get(id); let (idx, is_new) = if let Some(info) = existing { (info.entry_index, false) } else { (entry_index_provider.next(), true) }; let patch = if is_new { ConversationPatch::add_normalized_entry(idx, entry) } else { ConversationPatch::replace(idx, entry) }; patches.push(patch); self.tool_map.insert( id.clone(), ClaudeToolCallInfo { entry_index: idx, tool_name: tool_name_value, tool_data: tool_data.clone(), content: content_text, }, ); } ClaudeJson::ToolResult { .. } => { // Add proper ToolResult support to NormalizedEntry when the type system supports it } ClaudeJson::StreamEvent { event, parent_tool_use_id, .. } => match event { ClaudeStreamEvent::MessageStart { message } => { if message.role == "assistant" { if let Some(patch) = extract_model_name(self, message, entry_index_provider) { patches.push(patch); } if let Some(message_id) = message.id.clone() { self.streaming_messages.insert( message_id.clone(), StreamingMessageState::new(message.role.clone()), ); self.streaming_message_id = Some(message_id); } else { self.streaming_message_id = None; } } else { self.streaming_message_id = None; } } ClaudeStreamEvent::ContentBlockStart { index, content_block, } => { if let Some(state) = self .streaming_message_id .as_ref() .and_then(|id| self.streaming_messages.get_mut(id)) { state.content_block_start(*index, content_block.clone()); } } ClaudeStreamEvent::ContentBlockDelta { index, delta } => { if let Some(state) = self .streaming_message_id .as_ref() .and_then(|id| self.streaming_messages.get_mut(id)) && let Some(patch) = state.apply_content_block_delta( *index, delta, worktree_path, entry_index_provider, &mut self.last_assistant_message, ) { patches.push(patch); } } ClaudeStreamEvent::ContentBlockStop { .. } => {} ClaudeStreamEvent::MessageDelta { usage, .. } => { // do not report context token usage for subagents if parent_tool_use_id.is_none() && let Some(usage) = usage { let input_tokens = usage.input_tokens.unwrap_or(0) + usage.cache_creation_input_tokens.unwrap_or(0) + usage.cache_read_input_tokens.unwrap_or(0); let output_tokens = usage.output_tokens.unwrap_or(0); let total_tokens = input_tokens + output_tokens; self.context_tokens_used = total_tokens as u32; patches.push(self.add_token_usage_entry(entry_index_provider)); } } ClaudeStreamEvent::MessageStop => { if let Some(message_id) = self.streaming_message_id.take() { let _ = self.streaming_messages.remove(&message_id); } } ClaudeStreamEvent::Unknown => {} }, ClaudeJson::Result { is_error, model_usage, subtype, result, .. } => { // get the real model context window and correct the context usage entry if let Some(context_window) = model_usage.as_ref().and_then(|model_usage| { self.main_model_name .as_ref() .and_then(|name| model_usage.get(name)) .and_then(|usage| usage.context_window) }) { self.main_model_context_window = context_window; patches.push(self.add_token_usage_entry(entry_index_provider)); } if matches!(self.strategy, HistoryStrategy::AmpResume) && is_error.unwrap_or(false) { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: serde_json::to_string(claude_json) .unwrap_or_else(|_| "error".to_string()), metadata: Some( serde_json::to_value(claude_json).unwrap_or(serde_json::Value::Null), ), }; let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } else if matches!(subtype.as_deref(), Some("success")) && let Some(text) = result.as_ref().and_then(|v| v.as_str()) && (self.last_assistant_message.is_none() || matches!(&self.last_assistant_message, Some(message) if !message.contains(text))) { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::AssistantMessage, content: text.to_string(), metadata: Some( serde_json::to_value(claude_json).unwrap_or(serde_json::Value::Null), ), }; let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } } ClaudeJson::ApprovalRequested { tool_call_id, tool_name: _, approval_id, } => { self.replace_tool_entry_status( tool_call_id, ToolStatus::PendingApproval { approval_id: approval_id.clone(), }, worktree_path, &mut patches, ); } ClaudeJson::ApprovalResponse { tool_call_id, tool_name, approval_status, } => { if let Some(status) = ToolStatus::from_approval_status(approval_status) { self.replace_tool_entry_status( tool_call_id, status, worktree_path, &mut patches, ); } let entry_opt = match approval_status { ApprovalStatus::Pending | ApprovalStatus::Approved => None, ApprovalStatus::Denied { reason } => Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserFeedback { denied_tool: tool_name.clone(), }, content: reason .as_ref() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "User denied this tool use request".to_string()), metadata: None, }), ApprovalStatus::TimedOut => Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: format!("Approval timed out for tool {tool_name}"), metadata: None, }), }; if let Some(entry) = entry_opt { let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } } ClaudeJson::QuestionResponse { tool_call_id, tool_name: _, question_status, } => { let status = ToolStatus::from_question_status(question_status); self.replace_tool_entry_status(tool_call_id, status, worktree_path, &mut patches); let entry_opt = match question_status { QuestionStatus::Answered { answers } => { let qa_pairs: Vec = answers .iter() .map(|qa| AnsweredQuestion { question: qa.question.clone(), answer: qa.answer.clone(), }) .collect(); Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserAnsweredQuestions { answers: qa_pairs, }, content: format!( "Answered {} question{}", answers.len(), if answers.len() != 1 { "s" } else { "" } ), metadata: None, }) } QuestionStatus::TimedOut => None, }; if let Some(entry) = entry_opt { let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } } ClaudeJson::Unknown { data } => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!( "Unrecognized JSON message: {}", serde_json::to_value(data).unwrap_or_default() ), metadata: None, }; let idx = entry_index_provider.next(); patches.push(ConversationPatch::add_normalized_entry(idx, entry)); } ClaudeJson::ControlRequest { .. } | ClaudeJson::ControlResponse { .. } | ClaudeJson::ControlCancelRequest { .. } | ClaudeJson::RateLimitEvent { .. } => {} } patches } /// Generate concise, readable content for tool usage using structured data fn generate_concise_content( tool_data: &ClaudeToolData, action_type: &ActionType, worktree_path: &str, ) -> String { match action_type { ActionType::FileRead { path } => path.to_string(), ActionType::FileEdit { path, .. } => path.to_string(), ActionType::CommandRun { command, .. } => command.to_string(), ActionType::Search { query } => query.to_string(), ActionType::WebFetch { url } => url.to_string(), ActionType::TaskCreate { description, .. } => { if description.is_empty() { "Task".to_string() } else { format!("Task: `{description}`") } } ActionType::Tool { .. } => match tool_data { ClaudeToolData::NotebookEdit { notebook_path, .. } => { format!("`{}`", make_path_relative(notebook_path, worktree_path)) } ClaudeToolData::Unknown { .. } => { let name = tool_data.get_name(); if name.starts_with("mcp__") { let parts: Vec<&str> = name.split("__").collect(); if parts.len() >= 3 { return format!("mcp:{}:{}", parts[1], parts[2]); } } name.to_string() } _ => tool_data.get_name().to_string(), }, ActionType::PlanPresentation { plan } => plan.clone(), ActionType::TodoManagement { .. } => "TODO list updated".to_string(), ActionType::AskUserQuestion { questions } => { if questions.len() == 1 { questions[0].question.clone() } else { format!("{} questions", questions.len()) } } ActionType::Other { description: _ } => match tool_data { ClaudeToolData::LS { path } => { let relative_path = make_path_relative(path, worktree_path); if relative_path.is_empty() { "List directory".to_string() } else { format!("List directory: {relative_path}") } } ClaudeToolData::Glob { pattern, path, .. } => { if let Some(search_path) = path { format!( "Find files: `{}` in {}", pattern, make_path_relative(search_path, worktree_path) ) } else { format!("Find files: `{pattern}`") } } ClaudeToolData::Oracle { task, .. } => { if let Some(t) = task { format!("Oracle: `{t}`") } else { "Oracle".to_string() } } ClaudeToolData::Mermaid { .. } => "Mermaid diagram".to_string(), ClaudeToolData::CodebaseSearchAgent { query, path, .. } => { match (query.as_ref(), path.as_ref()) { (Some(q), Some(p)) if !q.is_empty() && !p.is_empty() => format!( "Codebase search: `{}` in {}", q, make_path_relative(p, worktree_path) ), (Some(q), _) if !q.is_empty() => format!("Codebase search: `{q}`"), _ => "Codebase search".to_string(), } } ClaudeToolData::UndoEdit { path, .. } => { if let Some(p) = path.as_ref() { let rel = make_path_relative(p, worktree_path); if rel.is_empty() { "Undo edit".to_string() } else { format!("Undo edit: `{rel}`") } } else { "Undo edit".to_string() } } _ => tool_data.get_name().to_string(), }, } } fn add_token_usage_entry( &mut self, entry_index_provider: &EntryIndexProvider, ) -> json_patch::Patch { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::TokenUsageInfo(crate::logs::TokenUsageInfo { total_tokens: self.context_tokens_used, model_context_window: self.main_model_context_window, }), content: format!( "Tokens used: {} / Context window: {}", self.context_tokens_used, self.main_model_context_window ), metadata: None, }; let idx = entry_index_provider.next(); ConversationPatch::add_normalized_entry(idx, entry) } } fn add_system_message( content: String, entry_index_provider: &EntryIndexProvider, ) -> json_patch::Patch { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content, metadata: None, }; let id = entry_index_provider.next(); ConversationPatch::add_normalized_entry(id, entry) } fn extract_model_name( processor: &mut ClaudeLogProcessor, message: &ClaudeMessage, entry_index_provider: &EntryIndexProvider, ) -> Option { if processor.model_name.is_none() && let Some(model) = message.model.as_ref() { processor.model_name = Some(model.clone()); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("System initialized with model: {model}"), metadata: None, }; let id = entry_index_provider.next(); Some(ConversationPatch::add_normalized_entry(id, entry)) } else { None } } struct StreamingMessageState { role: String, contents: HashMap, } impl StreamingMessageState { fn new(role: String) -> Self { Self { role, contents: HashMap::new(), } } fn content_block_start(&mut self, index: usize, content_block: ClaudeContentItem) { if let Some(state) = StreamingContentState::from_content_block(content_block) { self.contents.insert(index, state); } } fn apply_content_block_delta( &mut self, index: usize, delta: &ClaudeContentBlockDelta, worktree_path: &str, entry_index_provider: &EntryIndexProvider, last_assistant_message: &mut Option, ) -> Option { if let std::collections::hash_map::Entry::Vacant(e) = self.contents.entry(index) { let new_state = StreamingContentState::from_delta(delta)?; e.insert(new_state); } let entry_state = self.contents.get_mut(&index)?; entry_state.apply_content_delta(delta); let content_item = entry_state.to_content_item(); let entry = ClaudeLogProcessor::content_item_to_normalized_entry( &content_item, &self.role, worktree_path, last_assistant_message, )?; if let Some(existing_index) = entry_state.entry_index { Some(ConversationPatch::replace(existing_index, entry)) } else { let entry_index = entry_index_provider.next(); entry_state.entry_index = Some(entry_index); Some(ConversationPatch::add_normalized_entry(entry_index, entry)) } } fn content_entry_index(&self, content_index: usize) -> Option { self.contents .get(&content_index) .and_then(|s| s.entry_index) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum StreamingContentKind { Text, Thinking, } struct StreamingContentState { kind: StreamingContentKind, buffer: String, entry_index: Option, } impl StreamingContentState { fn from_content_block(content_block: ClaudeContentItem) -> Option { match content_block { ClaudeContentItem::Text { text } => Some(Self { kind: StreamingContentKind::Text, buffer: text, entry_index: None, }), ClaudeContentItem::Thinking { thinking } => Some(Self { kind: StreamingContentKind::Thinking, buffer: thinking, entry_index: None, }), _ => None, } } fn from_delta(delta: &ClaudeContentBlockDelta) -> Option { match delta { ClaudeContentBlockDelta::TextDelta { .. } => Some(Self { kind: StreamingContentKind::Text, buffer: String::new(), entry_index: None, }), ClaudeContentBlockDelta::ThinkingDelta { .. } => Some(Self { kind: StreamingContentKind::Thinking, buffer: String::new(), entry_index: None, }), _ => None, } } fn apply_content_delta(&mut self, delta: &ClaudeContentBlockDelta) { match (self.kind, delta) { (StreamingContentKind::Text, ClaudeContentBlockDelta::TextDelta { text }) => { self.buffer.push_str(text); } ( StreamingContentKind::Thinking, ClaudeContentBlockDelta::ThinkingDelta { thinking }, ) => { self.buffer.push_str(thinking); } // Signature deltas are sent at the end of thinking blocks for verification; // they don't contain display content so we ignore them. (StreamingContentKind::Thinking, ClaudeContentBlockDelta::SignatureDelta { .. }) => {} _ => { tracing::warn!( "Mismatched content types: delta {:?}, kind {:?}", delta, self.kind ); } } } fn to_content_item(&self) -> ClaudeContentItem { match self.kind { StreamingContentKind::Text => ClaudeContentItem::Text { text: self.buffer.clone(), }, StreamingContentKind::Thinking => ClaudeContentItem::Thinking { thinking: self.buffer.clone(), }, } } } // Data structures for parsing Claude's JSON output format #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClaudeJson { System { subtype: Option, session_id: Option, cwd: Option, tools: Option>, model: Option, #[serde(default, rename = "apiKeySource")] api_key_source: Option, status: Option, #[serde(default)] slash_commands: Vec, #[serde(default)] plugins: Vec, #[serde(default)] agents: Vec, #[serde(default)] task_id: Option, #[serde(default)] tool_use_id: Option, #[serde(default)] description: Option, #[serde(default)] task_type: Option, #[serde(default)] prompt: Option, #[serde(default)] summary: Option, #[serde(default)] last_tool_name: Option, }, Assistant { message: ClaudeMessage, session_id: Option, #[serde(default)] uuid: Option, }, User { message: ClaudeMessage, session_id: Option, #[serde(default)] uuid: Option, #[serde(default, rename = "isSynthetic")] is_synthetic: bool, #[serde(default, rename = "isReplay")] is_replay: bool, }, ToolUse { id: String, tool_name: String, #[serde(flatten)] tool_data: ClaudeToolData, session_id: Option, }, ToolResult { result: serde_json::Value, is_error: Option, session_id: Option, }, StreamEvent { event: ClaudeStreamEvent, #[serde(default)] session_id: Option, #[serde(default)] parent_tool_use_id: Option, #[serde(default)] uuid: Option, }, Result { #[serde(default)] subtype: Option, #[serde(default, alias = "isError")] is_error: Option, #[serde(default, alias = "durationMs")] duration_ms: Option, #[serde(default)] result: Option, #[serde(default)] error: Option, #[serde(default, alias = "numTurns")] num_turns: Option, #[serde(default, alias = "sessionId")] session_id: Option, #[serde(default, alias = "modelUsage")] model_usage: Option>, #[serde(default)] usage: Option, }, ApprovalRequested { tool_call_id: String, tool_name: String, approval_id: String, }, ApprovalResponse { tool_call_id: String, tool_name: String, approval_status: ApprovalStatus, }, QuestionResponse { tool_call_id: String, tool_name: String, question_status: QuestionStatus, }, ControlRequest { request_id: String, request: ControlRequestType, }, ControlResponse { response: ControlResponseType, }, ControlCancelRequest { request_id: String, }, RateLimitEvent { #[serde(default)] session_id: Option, #[serde(default)] rate_limit_info: Option, }, // Catch-all for unknown message types #[serde(untagged)] Unknown { #[serde(flatten)] data: HashMap, }, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ClaudePlugin { pub name: String, pub path: PathBuf, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct ClaudeMessage { pub id: Option, #[serde(rename = "type")] pub message_type: Option, pub role: String, pub model: Option, pub content: ClaudeMessageContent, pub stop_reason: Option, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] #[serde(untagged)] pub enum ClaudeMessageContent { Array(Vec), Text(String), } impl ClaudeMessageContent { fn items(&self) -> impl Iterator { match self { ClaudeMessageContent::Array(items) => items.iter(), ClaudeMessageContent::Text(_) => [].iter(), } } fn as_text(&self) -> Option<&String> { match self { ClaudeMessageContent::Text(s) => Some(s), _ => None, } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub enum ClaudeContentItem { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "thinking")] Thinking { thinking: String }, #[serde(rename = "tool_use")] ToolUse { id: String, #[serde(flatten)] tool_data: ClaudeToolData, }, #[serde(rename = "tool_result")] ToolResult { tool_use_id: String, content: serde_json::Value, is_error: Option, }, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub enum ClaudeStreamEvent { #[serde(rename = "message_start")] MessageStart { message: ClaudeMessage }, #[serde(rename = "content_block_start")] ContentBlockStart { index: usize, content_block: ClaudeContentItem, }, #[serde(rename = "content_block_delta")] ContentBlockDelta { index: usize, delta: ClaudeContentBlockDelta, }, #[serde(rename = "content_block_stop")] ContentBlockStop { index: usize }, #[serde(rename = "message_delta")] MessageDelta { #[serde(default)] delta: Option, #[serde(default)] usage: Option, }, #[serde(rename = "message_stop")] MessageStop, #[serde(other)] Unknown, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub enum ClaudeContentBlockDelta { #[serde(rename = "text_delta")] TextDelta { text: String }, #[serde(rename = "thinking_delta")] ThinkingDelta { thinking: String }, #[serde(rename = "signature_delta")] SignatureDelta { #[serde(default)] signature: String, }, #[serde(other)] Unknown, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] pub struct ClaudeMessageDelta { #[serde(default)] pub stop_reason: Option, #[serde(default)] pub stop_sequence: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] pub struct ClaudeUsage { #[serde(default)] pub input_tokens: Option, #[serde(default)] pub output_tokens: Option, #[serde(default, rename = "cache_creation_input_tokens")] pub cache_creation_input_tokens: Option, #[serde(default, rename = "cache_read_input_tokens")] pub cache_read_input_tokens: Option, #[serde(default)] pub service_tier: Option, } /// Per-model usage statistics from result message #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct ClaudeModelUsage { #[serde(default)] pub context_window: Option, } /// Structured tool data for Claude tools based on real samples #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "name", content = "input")] pub enum ClaudeToolData { #[serde(rename = "TodoWrite", alias = "todo_write")] TodoWrite { todos: Vec, }, #[serde(rename = "Task", alias = "task", alias = "Agent")] Task { subagent_type: Option, description: Option, prompt: Option, }, #[serde(rename = "Glob", alias = "glob")] Glob { #[serde(alias = "filePattern")] pattern: String, #[serde(default)] path: Option, #[serde(default)] limit: Option, }, #[serde(rename = "LS", alias = "list_directory", alias = "ls")] LS { path: String, }, #[serde(rename = "Read", alias = "read")] Read { #[serde(alias = "path")] file_path: String, }, #[serde(rename = "Bash", alias = "bash")] Bash { #[serde(alias = "cmd", alias = "command_line")] command: String, #[serde(default)] description: Option, }, #[serde(rename = "Grep", alias = "grep")] Grep { pattern: String, #[serde(default)] output_mode: Option, #[serde(default)] path: Option, }, ExitPlanMode { plan: String, }, #[serde(rename = "Edit", alias = "edit_file")] Edit { #[serde(alias = "path")] file_path: String, #[serde(alias = "old_str")] old_string: Option, #[serde(alias = "new_str")] new_string: Option, }, #[serde(rename = "MultiEdit", alias = "multi_edit")] MultiEdit { #[serde(alias = "path")] file_path: String, edits: Vec, }, #[serde(rename = "Write", alias = "create_file", alias = "write_file")] Write { #[serde(alias = "path")] file_path: String, content: String, }, #[serde(rename = "NotebookEdit", alias = "notebook_edit")] NotebookEdit { notebook_path: String, new_source: String, edit_mode: String, #[serde(default)] cell_id: Option, }, #[serde(rename = "WebFetch", alias = "read_web_page")] WebFetch { url: String, #[serde(default)] prompt: Option, }, #[serde(rename = "WebSearch", alias = "web_search")] WebSearch { query: String, #[serde(default)] num_results: Option, }, // Amp-only utilities for better UX #[serde(rename = "Oracle", alias = "oracle")] Oracle { #[serde(default)] task: Option, #[serde(default)] files: Option>, #[serde(default)] context: Option, }, #[serde(rename = "Mermaid", alias = "mermaid")] Mermaid { code: String, }, #[serde(rename = "CodebaseSearchAgent", alias = "codebase_search_agent")] CodebaseSearchAgent { #[serde(default)] query: Option, #[serde(default)] path: Option, #[serde(default)] include: Option>, #[serde(default)] exclude: Option>, #[serde(default)] limit: Option, }, #[serde(rename = "UndoEdit", alias = "undo_edit")] UndoEdit { #[serde(default, alias = "file_path")] path: Option, #[serde(default)] steps: Option, }, #[serde(rename = "TodoRead", alias = "todo_read")] TodoRead {}, AskUserQuestion { questions: Vec, }, #[serde(untagged)] Unknown { #[serde(flatten)] data: std::collections::HashMap, }, } // Helper structs for parsing tool_result content and generic tool input #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] struct ClaudeToolResultTextItem { text: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] struct ClaudeToolWithInput { #[serde(default)] input: serde_json::Value, } // Amp's claude-compatible Bash tool_result content format // Example content (often delivered as a JSON string): // {"output":"...","exitCode":0} #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] struct AmpBashResult { #[serde(default)] output: String, #[serde(rename = "exitCode")] exit_code: i32, } #[derive(Debug, Clone)] struct ClaudeToolCallInfo { entry_index: usize, tool_name: String, tool_data: ClaudeToolData, content: String, } /// A single question from AskUserQuestion tool input. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct AskUserQuestionInputItem { pub question: String, pub header: String, pub options: Vec, #[serde(rename = "multiSelect")] pub multi_select: bool, } /// An option for an AskUserQuestion question. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct AskUserQuestionInputOption { pub label: String, pub description: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct ClaudeTodoItem { #[serde(default)] pub id: Option, pub content: String, pub status: String, #[serde(default)] pub priority: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct ClaudeEditItem { pub old_string: Option, pub new_string: Option, } impl ClaudeToolData { pub fn get_name(&self) -> &str { match self { ClaudeToolData::TodoWrite { .. } => "TodoWrite", ClaudeToolData::Task { .. } => "Task", ClaudeToolData::Glob { .. } => "Glob", ClaudeToolData::LS { .. } => "LS", ClaudeToolData::Read { .. } => "Read", ClaudeToolData::Bash { .. } => "Bash", ClaudeToolData::Grep { .. } => "Grep", ClaudeToolData::ExitPlanMode { .. } => "ExitPlanMode", ClaudeToolData::Edit { .. } => "Edit", ClaudeToolData::MultiEdit { .. } => "MultiEdit", ClaudeToolData::Write { .. } => "Write", ClaudeToolData::NotebookEdit { .. } => "NotebookEdit", ClaudeToolData::WebFetch { .. } => "WebFetch", ClaudeToolData::WebSearch { .. } => "WebSearch", ClaudeToolData::TodoRead { .. } => "TodoRead", ClaudeToolData::Oracle { .. } => "Oracle", ClaudeToolData::Mermaid { .. } => "Mermaid", ClaudeToolData::CodebaseSearchAgent { .. } => "CodebaseSearchAgent", ClaudeToolData::UndoEdit { .. } => "UndoEdit", ClaudeToolData::AskUserQuestion { .. } => "AskUserQuestion", ClaudeToolData::Unknown { data } => data .get("name") .and_then(|v| v.as_str()) .unwrap_or("unknown"), } } } #[cfg(test)] mod tests { use super::*; use crate::logs::utils::{EntryIndexProvider, patch::extract_normalized_entry_from_patch}; fn patches_to_entries(patches: &[json_patch::Patch]) -> Vec { patches .iter() .filter_map(|patch| extract_normalized_entry_from_patch(patch).map(|(_, entry)| entry)) .collect() } fn normalize_helper( processor: &mut ClaudeLogProcessor, json: &ClaudeJson, worktree: &str, ) -> Vec { let provider = EntryIndexProvider::test_new(); let patches = processor.normalize_entries(json, worktree, &provider); patches_to_entries(&patches) } fn normalize(json: &ClaudeJson, worktree: &str) -> Vec { let mut processor = ClaudeLogProcessor::new(); normalize_helper(&mut processor, json, worktree) } #[test] fn test_claude_json_parsing() { let system_json = r#"{"type":"system","subtype":"init","session_id":"abc123","model":"claude-sonnet-4"}"#; let parsed: ClaudeJson = serde_json::from_str(system_json).unwrap(); // System messages no longer extract session_id assert_eq!(ClaudeLogProcessor::extract_session_id(&parsed), None); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 0); let assistant_json = r#" {"type":"assistant","message":{"type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hi! I'm Claude Code."}]}}"#; let parsed: ClaudeJson = serde_json::from_str(assistant_json).unwrap(); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 2); assert!(matches!( entries[0].entry_type, NormalizedEntryType::SystemMessage )); assert_eq!( entries[0].content, "System initialized with model: claude-sonnet-4-20250514" ); } #[test] fn test_assistant_message_parsing() { let assistant_json = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello world"}]},"session_id":"abc123"}"#; let parsed: ClaudeJson = serde_json::from_str(assistant_json).unwrap(); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 1); assert!(matches!( entries[0].entry_type, NormalizedEntryType::AssistantMessage )); assert_eq!(entries[0].content, "Hello world"); } #[test] fn test_result_message_emits_final_text_if_not_seen() { let result_json = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":6059,"result":"Final result"}"#; let parsed: ClaudeJson = serde_json::from_str(result_json).unwrap(); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 1); assert!(matches!( entries[0].entry_type, NormalizedEntryType::AssistantMessage )); assert_eq!(entries[0].content, "Final result"); } #[test] fn test_thinking_content() { let thinking_json = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me think about this..."}]}}"#; let parsed: ClaudeJson = serde_json::from_str(thinking_json).unwrap(); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 1); assert!(matches!( entries[0].entry_type, NormalizedEntryType::Thinking )); assert_eq!(entries[0].content, "Let me think about this..."); } #[test] fn test_todo_tool_empty_list() { // Test TodoWrite with empty todo list let empty_data = ClaudeToolData::TodoWrite { todos: vec![] }; let action_type = ClaudeLogProcessor::extract_action_type(&empty_data, "/tmp/test-worktree"); let result = ClaudeLogProcessor::generate_concise_content( &empty_data, &action_type, "/tmp/test-worktree", ); assert_eq!(result, "TODO list updated"); } #[test] fn test_glob_tool_content_extraction() { // Test Glob with pattern and path let glob_data = ClaudeToolData::Glob { pattern: "**/*.ts".to_string(), path: Some("/tmp/test-worktree/src".to_string()), limit: None, }; let action_type = ClaudeLogProcessor::extract_action_type(&glob_data, "/tmp/test-worktree"); let result = ClaudeLogProcessor::generate_concise_content( &glob_data, &action_type, "/tmp/test-worktree", ); assert_eq!(result, "**/*.ts"); } #[test] fn test_glob_tool_pattern_only() { // Test Glob with pattern only let glob_data = ClaudeToolData::Glob { pattern: "*.js".to_string(), path: None, limit: None, }; let action_type = ClaudeLogProcessor::extract_action_type(&glob_data, "/tmp/test-worktree"); let result = ClaudeLogProcessor::generate_concise_content( &glob_data, &action_type, "/tmp/test-worktree", ); assert_eq!(result, "*.js"); } #[test] fn test_ls_tool_content_extraction() { // Test LS with path let ls_data = ClaudeToolData::LS { path: "/tmp/test-worktree/components".to_string(), }; let action_type = ClaudeLogProcessor::extract_action_type(&ls_data, "/tmp/test-worktree"); let result = ClaudeLogProcessor::generate_concise_content( &ls_data, &action_type, "/tmp/test-worktree", ); assert_eq!(result, "List directory: components"); } #[test] fn test_path_relative_conversion() { // Test with relative path (should remain unchanged) let relative_result = make_path_relative("src/main.rs", "/tmp/test-worktree"); assert_eq!(relative_result, "src/main.rs"); // Test with absolute path (should become relative if possible) let test_worktree = "/tmp/test-worktree"; let absolute_path = format!("{test_worktree}/src/main.rs"); let absolute_result = make_path_relative(&absolute_path, test_worktree); assert_eq!(absolute_result, "src/main.rs"); } #[tokio::test] async fn test_streaming_patch_generation() { use std::sync::Arc; use workspace_utils::msg_store::MsgStore; let executor = ClaudeCode { claude_code_router: Some(false), plan: None, approvals: None, model: None, effort: None, agent: None, append_prompt: AppendPrompt::default(), dangerously_skip_permissions: None, cmd: crate::command::CmdOverrides { base_command_override: None, additional_params: None, env: None, }, approvals_service: None, disable_api_key: None, }; let msg_store = Arc::new(MsgStore::new()); let current_dir = std::path::PathBuf::from("/tmp/test-worktree"); // Push some test messages msg_store.push_stdout( r#"{"type":"system","subtype":"init","session_id":"test123"}"#.to_string(), ); msg_store.push_stdout(r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}"#.to_string()); msg_store.push_finished(); // Start normalization (this spawns async task) executor.normalize_logs(msg_store.clone(), ¤t_dir); // Give some time for async processing tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // Check that the history now contains patch messages let history = msg_store.get_history(); let patch_count = history .iter() .filter(|msg| matches!(msg, workspace_utils::log_msg::LogMsg::JsonPatch(_))) .count(); assert!( patch_count > 0, "Expected JsonPatch messages to be generated from streaming processing" ); } #[test] fn test_session_id_extraction() { let system_json = r#"{"type":"system","session_id":"test-session-123"}"#; let parsed: ClaudeJson = serde_json::from_str(system_json).unwrap(); // System messages no longer extract session_id assert_eq!(ClaudeLogProcessor::extract_session_id(&parsed), None); let tool_use_json = r#"{"id":"t1","type":"tool_use","tool_name":"read","input":{},"session_id":"another-session"}"#; let parsed_tool: ClaudeJson = serde_json::from_str(tool_use_json).unwrap(); assert_eq!( ClaudeLogProcessor::extract_session_id(&parsed_tool), Some("another-session".to_string()) ); } #[test] fn test_amp_tool_aliases_create_file_and_edit_file() { // Amp "create_file" should deserialize into Write with alias field "path" let assistant_with_create = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t1","name":"create_file","input":{"path":"/tmp/work/src/new.txt","content":"hello"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(assistant_with_create).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); match &entries[0].entry_type { NormalizedEntryType::ToolUse { action_type, .. } => match action_type { ActionType::FileEdit { path, .. } => assert_eq!(path, "src/new.txt"), other => panic!("Expected FileEdit, got {other:?}"), }, other => panic!("Expected ToolUse, got {other:?}"), } // Amp "edit_file" should deserialize into Edit with aliases for path/old_str/new_str let assistant_with_edit = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t2","name":"edit_file","input":{"path":"/tmp/work/README.md","old_str":"foo","new_str":"bar"}} ] } }"#; let parsed_edit: ClaudeJson = serde_json::from_str(assistant_with_edit).unwrap(); let entries = normalize(&parsed_edit, "/tmp/work"); assert_eq!(entries.len(), 1); match &entries[0].entry_type { NormalizedEntryType::ToolUse { action_type, .. } => match action_type { ActionType::FileEdit { path, .. } => assert_eq!(path, "README.md"), other => panic!("Expected FileEdit, got {other:?}"), }, other => panic!("Expected ToolUse, got {other:?}"), } } #[test] fn test_amp_tool_aliases_oracle_mermaid_codebase_undo() { // Oracle with task let oracle_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t1","name":"oracle","input":{"task":"Assess project status"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(oracle_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Oracle: `Assess project status`"); // Mermaid with code let mermaid_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t2","name":"mermaid","input":{"code":"graph TD; A-->B;"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(mermaid_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Mermaid diagram"); // CodebaseSearchAgent with query let csa_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t3","name":"codebase_search_agent","input":{"query":"TODO markers"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(csa_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Codebase search: `TODO markers`"); // UndoEdit shows file path when available let undo_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t4","name":"undo_edit","input":{"path":"README.md"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(undo_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Undo edit: `README.md`"); } #[test] fn test_amp_bash_and_task_content() { // Bash with alias field cmd let bash_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t1","name":"bash","input":{"cmd":"echo hello"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(bash_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); // Content should display the command assert_eq!(entries[0].content, "echo hello"); // Task content should include description/prompt wrapped in backticks let task_json = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t2","name":"task","input":{"subagent_type":"Task","prompt":"Add header to README"}} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(task_json).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Task: `Add header to README`"); } #[test] fn test_task_description_or_prompt_backticks() { // When description present, use it let with_desc = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t3","name":"Task","input":{ "subagent_type":"Task", "prompt":"Fallback prompt", "description":"Primary description" }} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(with_desc).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Task: `Primary description`"); // When description missing, fall back to prompt let no_desc = r#"{ "type":"assistant", "message":{ "role":"assistant", "content":[ {"type":"tool_use","id":"t4","name":"Task","input":{ "subagent_type":"Task", "prompt":"Only prompt" }} ] } }"#; let parsed: ClaudeJson = serde_json::from_str(no_desc).unwrap(); let entries = normalize(&parsed, "/tmp/work"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].content, "Task: `Only prompt`"); } #[test] fn test_tool_result_parsing_ignored() { let tool_result_json = r#"{"type":"tool_result","result":"File content here","is_error":false,"session_id":"test123"}"#; let parsed: ClaudeJson = serde_json::from_str(tool_result_json).unwrap(); // Test session ID extraction from ToolResult still works assert_eq!( ClaudeLogProcessor::extract_session_id(&parsed), Some("test123".to_string()) ); // ToolResult messages should be ignored (produce no entries) until proper support is added let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 0); } #[test] fn test_content_item_tool_result_ignored() { let assistant_with_tool_result = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_result","tool_use_id":"tool_123","content":"Operation completed","is_error":false}]}}"#; let parsed: ClaudeJson = serde_json::from_str(assistant_with_tool_result).unwrap(); // ToolResult content items should be ignored (produce no entries) until proper support is added let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 0); } #[test] fn test_api_key_source_warning() { // Test with ANTHROPIC_API_KEY - should generate warning let system_with_env_key = r#"{"type":"system","subtype":"init","apiKeySource":"ANTHROPIC_API_KEY","session_id":"test123"}"#; let parsed: ClaudeJson = serde_json::from_str(system_with_env_key).unwrap(); let entries = normalize(&parsed, ""); assert_eq!(entries.len(), 1); assert!(matches!( entries[0].entry_type, NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, )); assert_eq!( entries[0].content, "Claude Code + ANTHROPIC_API_KEY detected. Usage will be billed via Anthropic pay-as-you-go instead of your Claude subscription. If this is unintended, please select the `disable_api_key` checkbox in the conding-agent-configurations settings page." ); // Test with managed API key source - should not generate warning let system_with_managed_key = r#"{"type":"system","subtype":"init","apiKeySource":"/login managed key","session_id":"test123"}"#; let parsed_managed: ClaudeJson = serde_json::from_str(system_with_managed_key).unwrap(); let entries_managed = normalize(&parsed_managed, ""); assert_eq!(entries_managed.len(), 0); // No warning for managed key // Test with other apiKeySource values - should not generate warning let system_other_key = r#"{"type":"system","subtype":"init","apiKeySource":"OTHER_KEY","session_id":"test123"}"#; let parsed_other: ClaudeJson = serde_json::from_str(system_other_key).unwrap(); let entries_other = normalize(&parsed_other, ""); assert_eq!(entries_other.len(), 0); // No warning for other keys // Test with missing apiKeySource - should not generate warning let system_no_key = r#"{"type":"system","subtype":"init","session_id":"test123"}"#; let parsed_no_key: ClaudeJson = serde_json::from_str(system_no_key).unwrap(); let entries_no_key = normalize(&parsed_no_key, ""); assert_eq!(entries_no_key.len(), 0); // No warning when field is missing } #[test] fn test_mixed_content_with_thinking_ignores_tool_result() { let complex_assistant_json = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I need to read the file first"},{"type":"text","text":"I'll help you with that"},{"type":"tool_result","tool_use_id":"tool_789","content":"Success","is_error":false}]}}"#; let parsed: ClaudeJson = serde_json::from_str(complex_assistant_json).unwrap(); let entries = normalize(&parsed, ""); // Only thinking and text entries should be processed, tool_result ignored assert_eq!(entries.len(), 2); // Check thinking entry assert!(matches!( entries[0].entry_type, NormalizedEntryType::Thinking )); assert_eq!(entries[0].content, "I need to read the file first"); // Check assistant message assert!(matches!( entries[1].entry_type, NormalizedEntryType::AssistantMessage )); assert_eq!(entries[1].content, "I'll help you with that"); // ToolResult entry is ignored - no third entry } #[test] fn test_control_request_with_permission_suggestions() { let control_request_json = r#"{"type":"control_request","request_id":"f559d907-b139-475b-addd-79c05591eb99","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"./gradlew :web:testApi","timeout":300000,"description":"Run API tests"},"permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"./gradlew :web:testApi:"}],"behavior":"allow","destination":"localSettings"}],"tool_use_id":"toolu_014PR3WXsJfiftSCbjcjEbeM"}}"#; let parsed: ClaudeJson = serde_json::from_str(control_request_json).unwrap(); assert!(matches!(parsed, ClaudeJson::ControlRequest { .. })); } } ================================================ FILE: crates/executors/src/executors/codex/client.rs ================================================ use std::{ collections::{HashMap, VecDeque}, io, sync::{ Arc, OnceLock, atomic::{AtomicBool, Ordering}, }, }; use async_trait::async_trait; use codex_app_server_protocol::{ ClientInfo, ClientNotification, ClientRequest, CommandExecutionApprovalDecision, CommandExecutionRequestApprovalResponse, ConfigBatchWriteParams, ConfigEdit, ConfigReadParams, ConfigReadResponse, ConfigWriteResponse, FileChangeApprovalDecision, FileChangeRequestApprovalResponse, GetAccountParams, GetAccountRateLimitsResponse, GetAccountResponse, InitializeCapabilities, InitializeParams, InitializeResponse, ItemCompletedNotification, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, ListMcpServerStatusParams, ListMcpServerStatusResponse, RequestId, ReviewStartParams, ReviewStartResponse, ReviewTarget, ServerRequest, ThreadCompactStartParams, ThreadCompactStartResponse, ThreadForkParams, ThreadForkResponse, ThreadItem, ThreadReadParams, ThreadReadResponse, ThreadStartParams, ThreadStartResponse, ToolRequestUserInputAnswer, ToolRequestUserInputQuestion, ToolRequestUserInputResponse, TurnCompletedNotification, TurnStartParams, TurnStartResponse, TurnStatus, UserInput, }; use codex_protocol::config_types::{CollaborationMode, ModeKind, Settings}; use futures::TryFutureExt; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{self, Value}; use tokio::{ io::{AsyncWrite, AsyncWriteExt, BufWriter}, sync::Mutex, }; use tokio_util::sync::CancellationToken; use workspace_utils::approvals::{ApprovalStatus, QuestionStatus}; use super::jsonrpc::{JsonRpcCallbacks, JsonRpcPeer}; use crate::{ approvals::{ExecutorApprovalError, ExecutorApprovalService}, env::RepoContext, executors::{ExecutorError, codex::normalize_logs::Approval}, }; struct PendingPlan { item_id: String, } pub struct AppServerClient { rpc: OnceLock, log_writer: LogWriter, approvals: Option>, thread_id: Mutex>, pending_feedback: Mutex>, auto_approve: bool, plan_mode: bool, resolved_model: OnceLock, pending_plan: Mutex>, repo_context: RepoContext, commit_reminder: bool, commit_reminder_prompt: String, commit_reminder_sent: AtomicBool, cancel: CancellationToken, } impl AppServerClient { #[allow(clippy::too_many_arguments)] pub fn new( log_writer: LogWriter, approvals: Option>, auto_approve: bool, plan_mode: bool, repo_context: RepoContext, commit_reminder: bool, commit_reminder_prompt: String, cancel: CancellationToken, ) -> Arc { Arc::new(Self { rpc: OnceLock::new(), log_writer, approvals, auto_approve, plan_mode, resolved_model: OnceLock::new(), pending_plan: Mutex::new(None), thread_id: Mutex::new(None), pending_feedback: Mutex::new(VecDeque::new()), repo_context, commit_reminder, commit_reminder_prompt, commit_reminder_sent: AtomicBool::new(false), cancel, }) } pub fn connect(&self, peer: JsonRpcPeer) { let _ = self.rpc.set(peer); } pub fn set_resolved_model(&self, model: String) { let _ = self.resolved_model.set(model); } fn rpc(&self) -> &JsonRpcPeer { self.rpc.get().expect("Codex RPC peer not attached") } pub fn log_writer(&self) -> &LogWriter { &self.log_writer } pub async fn initialize(&self) -> Result<(), ExecutorError> { let request = ClientRequest::Initialize { request_id: self.next_request_id(), params: InitializeParams { client_info: ClientInfo { name: "vibe-codex-executor".to_string(), title: None, version: env!("CARGO_PKG_VERSION").to_string(), }, capabilities: Some(InitializeCapabilities { experimental_api: true, ..Default::default() }), }, }; self.send_request::(request, "initialize") .await?; self.send_message(&ClientNotification::Initialized).await } pub async fn thread_start( &self, params: ThreadStartParams, ) -> Result { let request = ClientRequest::ThreadStart { request_id: self.next_request_id(), params, }; self.send_request(request, "thread/start").await } pub async fn thread_fork( &self, params: ThreadForkParams, ) -> Result { let request = ClientRequest::ThreadFork { request_id: self.next_request_id(), params, }; self.send_request(request, "thread/fork").await } pub async fn turn_start( &self, thread_id: String, input: Vec, ) -> Result { self.turn_start_with_mode(thread_id, input, None).await } pub async fn turn_start_with_mode( &self, thread_id: String, input: Vec, collaboration_mode: Option, ) -> Result { let request = ClientRequest::TurnStart { request_id: self.next_request_id(), params: TurnStartParams { thread_id, input, collaboration_mode, ..Default::default() }, }; self.send_request(request, "turn/start").await } fn collaboration_mode(&self, mode: ModeKind) -> Result { let model = self.resolved_model.get().cloned().ok_or_else(|| { tracing::error!("collaboration_mode called before resolved_model was set"); ExecutorError::Io(io::Error::other( "resolved model not available for collaboration mode", )) })?; Ok(CollaborationMode { mode, settings: Settings { model, reasoning_effort: None, developer_instructions: None, }, }) } pub fn initial_collaboration_mode(&self) -> Result { if self.plan_mode { self.collaboration_mode(ModeKind::Plan) } else { self.collaboration_mode(ModeKind::Default) } } pub async fn get_account(&self) -> Result { let request = ClientRequest::GetAccount { request_id: self.next_request_id(), params: GetAccountParams { refresh_token: false, }, }; self.send_request(request, "account/read").await } pub async fn start_review( &self, thread_id: String, target: ReviewTarget, ) -> Result { let request = ClientRequest::ReviewStart { request_id: self.next_request_id(), params: ReviewStartParams { thread_id, target, delivery: None, }, }; self.send_request(request, "reviewStart").await } pub async fn list_mcp_server_status( &self, cursor: Option, ) -> Result { let request = ClientRequest::McpServerStatusList { request_id: self.next_request_id(), params: ListMcpServerStatusParams { cursor, limit: None, }, }; self.send_request(request, "mcpServerStatus/list").await } pub async fn thread_compact_start( &self, thread_id: String, ) -> Result { let request = ClientRequest::ThreadCompactStart { request_id: self.next_request_id(), params: ThreadCompactStartParams { thread_id }, }; self.send_request(request, "thread/compact/start").await } pub async fn thread_read( &self, thread_id: String, ) -> Result { let request = ClientRequest::ThreadRead { request_id: self.next_request_id(), params: ThreadReadParams { thread_id, include_turns: false, }, }; self.send_request(request, "thread/read").await } pub async fn config_batch_write( &self, edits: Vec, ) -> Result { let request = ClientRequest::ConfigBatchWrite { request_id: self.next_request_id(), params: ConfigBatchWriteParams { edits, file_path: None, expected_version: None, reload_user_config: false, }, }; self.send_request(request, "config/batchWrite").await } pub async fn config_read( &self, cwd: Option, ) -> Result { let request = ClientRequest::ConfigRead { request_id: self.next_request_id(), params: ConfigReadParams { include_layers: false, cwd, }, }; self.send_request(request, "config/read").await } pub async fn get_account_rate_limits( &self, ) -> Result { let request = ClientRequest::GetAccountRateLimits { request_id: self.next_request_id(), params: None, }; self.send_request(request, "account/rateLimits/read").await } async fn handle_server_request( &self, peer: &JsonRpcPeer, request: ServerRequest, ) -> Result<(), ExecutorError> { match request { ServerRequest::FileChangeRequestApproval { request_id, params } => { let call_id = params.item_id.clone(); let status = self .request_tool_approval("edit", "codex.apply_patch", &call_id) .await .inspect_err(|err| { if !matches!( err, ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled) ) { tracing::error!( "Codex file_change approval failed for item_id={}: {err}", call_id ); } })?; self.log_writer .log_raw( &Approval::approval_response( call_id, "codex.apply_patch".to_string(), status.clone(), ) .raw(), ) .await?; let (decision, feedback) = self.file_change_decision(&status); let response = FileChangeRequestApprovalResponse { decision }; send_server_response(peer, request_id, response).await?; if let Some(message) = feedback { tracing::debug!("queueing file change denial feedback: {message}"); self.enqueue_feedback(message).await; } Ok(()) } ServerRequest::CommandExecutionRequestApproval { request_id, params } => { let call_id = params.item_id.clone(); let status = self .request_tool_approval("bash", "codex.exec_command", &call_id) .await .inspect_err(|err| { if !matches!( err, ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled) ) { tracing::error!( "Codex command_execution approval failed for item_id={}: {err}", call_id ); } })?; self.log_writer .log_raw( &Approval::approval_response( call_id, "codex.exec_command".to_string(), status.clone(), ) .raw(), ) .await?; let (decision, feedback) = self.command_execution_decision(&status); let response = CommandExecutionRequestApprovalResponse { decision }; send_server_response(peer, request_id, response).await?; if let Some(message) = feedback { tracing::debug!("queueing exec denial feedback: {message}"); self.enqueue_feedback(message).await; } Ok(()) } ServerRequest::ToolRequestUserInput { request_id, params } => { let call_id = params.item_id.clone(); let question_count = params.questions.len(); let status = self .request_question_answer(question_count, &call_id) .await .inspect_err(|err| { if !matches!( err, ExecutorError::ExecutorApprovalError(ExecutorApprovalError::Cancelled) ) { tracing::error!( "Codex question approval failed for call_id={}: {err}", call_id ); } })?; self.log_writer .log_raw(&Approval::question_response(call_id.clone(), status.clone()).raw()) .await?; let response = match &status { QuestionStatus::Answered { answers } => { let answers_map: HashMap> = answers .iter() .map(|qa| (qa.question.clone(), qa.answer.clone())) .collect(); answers_to_codex_format(¶ms.questions, &answers_map) } _ => ToolRequestUserInputResponse { answers: HashMap::new(), }, }; send_server_response(peer, request_id, response).await?; Ok(()) } ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::McpServerElicitationRequest { .. } | ServerRequest::PermissionsRequestApproval { .. } => { tracing::warn!("received unhandled v2 server request: {:?}", request); let response = JSONRPCResponse { id: request.id().clone(), result: Value::Null, }; peer.send(&response).await } ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { tracing::error!( "received deprecated v1 server request (session may have been started with legacy API): {:?}", request ); Err(ExecutorApprovalError::RequestFailed( "deprecated v1 server request".to_string(), ) .into()) } } } async fn request_tool_approval( &self, tool_name: &str, display_tool_name: &str, tool_call_id: &str, ) -> Result { if self.auto_approve { return Ok(ApprovalStatus::Approved); } let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable)?; let approval_id = approval_service .create_tool_approval(tool_name) .or_else(|err| async { self.handle_approval_error(display_tool_name, tool_call_id) .await; Err(err) }) .await?; let _ = self .log_writer .log_raw( &Approval::approval_requested( tool_call_id.to_string(), display_tool_name.to_string(), approval_id.clone(), ) .raw(), ) .await; approval_service .wait_tool_approval(&approval_id, self.cancel.clone()) .or_else(|err| async { self.handle_approval_error(display_tool_name, tool_call_id) .await; Err(err) }) .await .map_err(ExecutorError::from) } async fn handle_approval_error(&self, display_tool_name: &str, tool_call_id: &str) { let _ = self .log_writer .log_raw( &Approval::approval_response( tool_call_id.to_string(), display_tool_name.to_string(), ApprovalStatus::TimedOut, ) .raw(), ) .await; } async fn request_question_answer( &self, question_count: usize, tool_call_id: &str, ) -> Result { let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable)?; let approval_id = approval_service .create_question_approval("question", question_count) .or_else(|err| async { self.handle_question_error(tool_call_id).await; Err(err) }) .await?; let _ = self .log_writer .log_raw( &Approval::approval_requested( tool_call_id.to_string(), "codex.question".to_string(), approval_id.clone(), ) .raw(), ) .await; approval_service .wait_question_answer(&approval_id, self.cancel.clone()) .or_else(|err| async { self.handle_question_error(tool_call_id).await; Err(err) }) .await .map_err(ExecutorError::from) } async fn handle_question_error(&self, tool_call_id: &str) { let _ = self .log_writer .log_raw( &Approval::question_response(tool_call_id.to_string(), QuestionStatus::TimedOut) .raw(), ) .await; } async fn handle_plan_completed(&self, plan: PendingPlan) -> Result { let approval_service = self .approvals .as_ref() .ok_or(ExecutorApprovalError::ServiceUnavailable)?; let approval_id = approval_service .create_tool_approval("plan") .or_else(|err| async { self.handle_approval_error("codex.plan", &plan.item_id) .await; Err(err) }) .await?; let _ = self .log_writer .log_raw( &Approval::approval_requested( plan.item_id.clone(), "codex.plan".to_string(), approval_id.clone(), ) .raw(), ) .await; let status = approval_service .wait_tool_approval(&approval_id, self.cancel.clone()) .or_else(|err| async { self.handle_approval_error("codex.plan", &plan.item_id) .await; Err(err) }) .await .map_err(ExecutorError::from)?; self.log_writer .log_raw( &Approval::approval_response( plan.item_id, "codex.plan".to_string(), status.clone(), ) .raw(), ) .await?; let Some(thread_id) = self.thread_id.lock().await.clone() else { return Ok(true); }; match status { ApprovalStatus::Approved => { self.spawn_turn_start( thread_id, "Implement the plan.".to_string(), Some(self.collaboration_mode(ModeKind::Default)?), ); Ok(false) } ApprovalStatus::Denied { reason } => { let feedback = reason .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); if let Some(feedback_text) = feedback { self.spawn_turn_start( thread_id, format!("User feedback on the plan: {feedback_text}"), Some(self.collaboration_mode(ModeKind::Plan)?), ); Ok(false) } else { Ok(true) } } ApprovalStatus::TimedOut | ApprovalStatus::Pending => Ok(true), } } pub async fn register_session(&self, thread_id: &str) -> Result<(), ExecutorError> { { let mut guard = self.thread_id.lock().await; guard.replace(thread_id.to_string()); } self.flush_pending_feedback().await; Ok(()) } async fn send_message(&self, message: &M) -> Result<(), ExecutorError> where M: Serialize + Sync, { self.rpc().send(message).await } async fn send_request(&self, request: ClientRequest, label: &str) -> Result where R: DeserializeOwned + std::fmt::Debug, { let request_id = request_id(&request); self.rpc() .request(request_id, &request, label, self.cancel.clone()) .await } fn next_request_id(&self) -> RequestId { self.rpc().next_request_id() } fn command_execution_decision( &self, status: &ApprovalStatus, ) -> (CommandExecutionApprovalDecision, Option) { if self.auto_approve { return (CommandExecutionApprovalDecision::AcceptForSession, None); } match status { ApprovalStatus::Approved => (CommandExecutionApprovalDecision::Accept, None), ApprovalStatus::Denied { reason } => { let feedback = reason .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); if feedback.is_some() { (CommandExecutionApprovalDecision::Cancel, feedback) } else { (CommandExecutionApprovalDecision::Decline, None) } } ApprovalStatus::TimedOut => (CommandExecutionApprovalDecision::Decline, None), ApprovalStatus::Pending => (CommandExecutionApprovalDecision::Decline, None), } } fn file_change_decision( &self, status: &ApprovalStatus, ) -> (FileChangeApprovalDecision, Option) { if self.auto_approve { return (FileChangeApprovalDecision::AcceptForSession, None); } match status { ApprovalStatus::Approved => (FileChangeApprovalDecision::Accept, None), ApprovalStatus::Denied { reason } => { let feedback = reason .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); if feedback.is_some() { (FileChangeApprovalDecision::Cancel, feedback) } else { (FileChangeApprovalDecision::Decline, None) } } ApprovalStatus::TimedOut => (FileChangeApprovalDecision::Decline, None), ApprovalStatus::Pending => (FileChangeApprovalDecision::Decline, None), } } async fn enqueue_feedback(&self, message: String) { if message.trim().is_empty() { return; } let mut guard = self.pending_feedback.lock().await; guard.push_back(message); } /// Sends pending feedback messages as new turns. /// Returns `true` if any messages were sent. async fn flush_pending_feedback(&self) -> bool { let messages: Vec = { let mut guard = self.pending_feedback.lock().await; guard.drain(..).collect() }; if messages.is_empty() { return false; } let Some(thread_id) = self.thread_id.lock().await.clone() else { tracing::warn!( "pending Codex feedback but thread id unavailable; dropping {} messages", messages.len() ); return false; }; let mut sent = false; for message in messages { let trimmed = message.trim(); if trimmed.is_empty() { continue; } self.spawn_user_message(thread_id.clone(), format!("User feedback: {trimmed}")); sent = true; } sent } fn spawn_turn_start( &self, thread_id: String, message: String, collaboration_mode: Option, ) { let peer = self.rpc().clone(); let cancel = self.cancel.clone(); let request = ClientRequest::TurnStart { request_id: peer.next_request_id(), params: TurnStartParams { thread_id, input: vec![UserInput::Text { text: message, text_elements: vec![], }], collaboration_mode, ..Default::default() }, }; tokio::spawn(async move { if let Err(err) = peer .request::( request_id(&request), &request, "turn/start", cancel, ) .await { tracing::error!("failed to send user message: {err}"); } }); } fn spawn_user_message(&self, thread_id: String, message: String) { self.spawn_turn_start(thread_id, message, None); } } #[async_trait] impl JsonRpcCallbacks for AppServerClient { async fn on_request( &self, peer: &JsonRpcPeer, raw: &str, request: JSONRPCRequest, ) -> Result<(), ExecutorError> { self.log_writer.log_raw(raw).await?; match ServerRequest::try_from(request.clone()) { Ok(server_request) => self.handle_server_request(peer, server_request).await, Err(err) => { tracing::debug!("Unhandled server request `{}`: {err}", request.method); let response = JSONRPCResponse { id: request.id, result: Value::Null, }; peer.send(&response).await } } } async fn on_response( &self, _peer: &JsonRpcPeer, raw: &str, _response: &JSONRPCResponse, ) -> Result<(), ExecutorError> { self.log_writer.log_raw(raw).await } async fn on_error( &self, _peer: &JsonRpcPeer, raw: &str, _error: &JSONRPCError, ) -> Result<(), ExecutorError> { self.log_writer.log_raw(raw).await } async fn on_notification( &self, _peer: &JsonRpcPeer, raw: &str, notification: JSONRPCNotification, ) -> Result { self.log_writer.log_raw(raw).await?; let method = notification.method.as_str(); // Detect completed plan items in the notification stream if self.plan_mode && method == "item/completed" && let Some(ref params) = notification.params && let Ok(completed) = serde_json::from_value::(params.clone()) && let ThreadItem::Plan { id, .. } = completed.item { *self.pending_plan.lock().await = Some(PendingPlan { item_id: id }); } // V2 turn completion detection if method == "turn/completed" { let mut keep_alive = false; if let Some(params) = notification.params && let Ok(completed) = serde_json::from_value::(params) && completed.turn.status == TurnStatus::Interrupted { tracing::debug!("codex turn interrupted; flushing feedback queue"); if self.flush_pending_feedback().await { keep_alive = true; } } // Handle plan approval on turn completion let pending = if self.plan_mode { self.pending_plan.lock().await.take() } else { None }; if let Some(plan) = pending { return self.handle_plan_completed(plan).await; } // Handle commit reminder on turn completion if !keep_alive && self.commit_reminder && !self.commit_reminder_sent.swap(true, Ordering::SeqCst) && let status = self.repo_context.check_uncommitted_changes().await && !status.is_empty() && let Some(thread_id) = self.thread_id.lock().await.clone() { let prompt = format!("{}\n{}", self.commit_reminder_prompt, status); self.spawn_user_message(thread_id, prompt); return Ok(false); } return Ok(!keep_alive); } Ok(false) } async fn on_non_json(&self, raw: &str) -> Result<(), ExecutorError> { self.log_writer.log_raw(raw).await?; Ok(()) } } async fn send_server_response( peer: &JsonRpcPeer, request_id: RequestId, response: T, ) -> Result<(), ExecutorError> where T: Serialize, { let payload = JSONRPCResponse { id: request_id, result: serde_json::to_value(response) .map_err(|err| ExecutorError::Io(io::Error::other(err.to_string())))?, }; peer.send(&payload).await } /// Convert our `HashMap>` answer format to /// Codex's `HashMap` format. fn answers_to_codex_format( questions: &[ToolRequestUserInputQuestion], answers: &HashMap>, ) -> ToolRequestUserInputResponse { let codex_answers = questions .iter() .filter_map(|q| { answers.get(&q.question).map(|answer_vec| { ( q.id.clone(), ToolRequestUserInputAnswer { answers: answer_vec.clone(), }, ) }) }) .collect(); ToolRequestUserInputResponse { answers: codex_answers, } } fn request_id(request: &ClientRequest) -> RequestId { match request { ClientRequest::Initialize { request_id, .. } | ClientRequest::ThreadStart { request_id, .. } | ClientRequest::ThreadFork { request_id, .. } | ClientRequest::TurnStart { request_id, .. } | ClientRequest::GetAccount { request_id, .. } | ClientRequest::ReviewStart { request_id, .. } | ClientRequest::McpServerStatusList { request_id, .. } | ClientRequest::ThreadCompactStart { request_id, .. } | ClientRequest::ThreadRead { request_id, .. } | ClientRequest::ConfigRead { request_id, .. } | ClientRequest::ConfigBatchWrite { request_id, .. } | ClientRequest::GetAccountRateLimits { request_id, .. } => request_id.clone(), _ => unreachable!("request_id called for unsupported request variant"), } } #[derive(Clone)] pub struct LogWriter { writer: Arc>>>, } impl LogWriter { pub fn new(writer: impl AsyncWrite + Send + Unpin + 'static) -> Self { Self { writer: Arc::new(Mutex::new(BufWriter::new(Box::new(writer)))), } } pub async fn log_raw(&self, raw: &str) -> Result<(), ExecutorError> { let mut guard = self.writer.lock().await; guard .write_all(raw.as_bytes()) .await .map_err(ExecutorError::Io)?; guard.write_all(b"\n").await.map_err(ExecutorError::Io)?; guard.flush().await.map_err(ExecutorError::Io)?; Ok(()) } } ================================================ FILE: crates/executors/src/executors/codex/init_prompt.md ================================================ Generate a file named AGENTS.md that serves as a contributor guide for this repository. Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. Document Requirements - Title the document "Repository Guidelines". - Use Markdown headings (#, ##, etc.) for structure. - Keep the document concise. 200-400 words is optimal. - Keep explanations short, direct, and specific to this repository. - Provide examples where helpful (commands, directory paths, naming patterns). - Maintain a professional, instructional tone. Recommended Sections Project Structure & Module Organization - Outline the project structure, including where the source code, tests, and assets are located. Build, Test, and Development Commands - List key commands for building, testing, and running locally (e.g., npm test, make build). - Briefly explain what each command does. Coding Style & Naming Conventions - Specify indentation rules, language-specific style preferences, and naming patterns. - Include any formatting or linting tools used. Testing Guidelines - Identify testing frameworks and coverage requirements. - State test naming conventions and how to run tests. Commit & Pull Request Guidelines - Summarize commit message conventions found in the project’s Git history. - Outline pull request requirements (descriptions, linked issues, screenshots, etc.). (Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. ================================================ FILE: crates/executors/src/executors/codex/jsonrpc.rs ================================================ //! Minimal JSON-RPC helper tailored for the Codex executor. //! //! We keep this bespoke layer because the codex-app-server client must handle server-initiated //! requests as well as client-initiated requests. When a bidirectional client that //! supports this pattern is available, this module should be straightforward to //! replace. use std::{ collections::HashMap, fmt::Debug, io, sync::{ Arc, atomic::{AtomicI64, Ordering}, }, }; use async_trait::async_trait; use codex_app_server_protocol::{ JSONRPCError, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, RequestId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{ChildStdin, ChildStdout}, sync::{Mutex, oneshot}, }; use tokio_util::sync::CancellationToken; use crate::executors::{ExecutorError, ExecutorExitResult}; #[derive(Debug)] pub enum PendingResponse { Result(Value), Error(JSONRPCError), Shutdown, } #[derive(Clone)] pub struct ExitSignalSender { inner: Arc>>>, } impl ExitSignalSender { pub fn new(sender: oneshot::Sender) -> Self { Self { inner: Arc::new(Mutex::new(Some(sender))), } } pub async fn send_exit_signal(&self, result: ExecutorExitResult) { if let Some(sender) = self.inner.lock().await.take() { let _ = sender.send(result); } } } #[derive(Clone)] pub struct JsonRpcPeer { stdin: Arc>, pending: Arc>>>, id_counter: Arc, } impl JsonRpcPeer { pub fn spawn( stdin: ChildStdin, stdout: ChildStdout, callbacks: Arc, exit_tx: ExitSignalSender, cancel: CancellationToken, ) -> Self { let peer = Self { stdin: Arc::new(Mutex::new(stdin)), pending: Arc::new(Mutex::new(HashMap::new())), id_counter: Arc::new(AtomicI64::new(1)), }; let reader_peer = peer.clone(); let callbacks = callbacks.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stdout); let mut buffer = String::new(); loop { buffer.clear(); tokio::select! { _ = cancel.cancelled() => { tracing::debug!("Codex executor cancelled"); break; } read_result = reader.read_line(&mut buffer) => { match read_result { Ok(0) => break, Ok(_) => { let line = buffer.trim_end_matches(['\n', '\r']); if line.is_empty() { continue; } match serde_json::from_str::(line) { Ok(JSONRPCMessage::Response(response)) => { let request_id = response.id.clone(); let result = response.result.clone(); if callbacks .on_response(&reader_peer, line, &response) .await .is_err() { break; } reader_peer .resolve(request_id, PendingResponse::Result(result)) .await; } Ok(JSONRPCMessage::Error(error)) => { let request_id = error.id.clone(); if callbacks .on_error(&reader_peer, line, &error) .await .is_err() { break; } reader_peer .resolve(request_id, PendingResponse::Error(error)) .await; } Ok(JSONRPCMessage::Request(request)) => { if callbacks .on_request(&reader_peer, line, request) .await .is_err() { break; } } Ok(JSONRPCMessage::Notification(notification)) => { match callbacks .on_notification(&reader_peer, line, notification) .await { // finished Ok(true) => break, Ok(false) => {} Err(_) => { break; } } } Err(_) => { if callbacks.on_non_json(line).await.is_err() { break; } } } } Err(err) => { tracing::warn!("Error reading Codex output: {err}"); break; } } } } } exit_tx.send_exit_signal(ExecutorExitResult::Success).await; let _ = reader_peer.shutdown().await; }); peer } pub fn next_request_id(&self) -> RequestId { RequestId::Integer(self.id_counter.fetch_add(1, Ordering::Relaxed)) } pub async fn register(&self, request_id: RequestId) -> PendingReceiver { let (sender, receiver) = oneshot::channel(); self.pending.lock().await.insert(request_id, sender); receiver } pub async fn resolve(&self, request_id: RequestId, response: PendingResponse) { if let Some(sender) = self.pending.lock().await.remove(&request_id) { let _ = sender.send(response); } } pub async fn shutdown(&self) -> Result<(), ExecutorError> { let mut pending = self.pending.lock().await; for (_, sender) in pending.drain() { let _ = sender.send(PendingResponse::Shutdown); } Ok(()) } pub async fn send(&self, message: &T) -> Result<(), ExecutorError> where T: Serialize + Sync, { let raw = serde_json::to_string(message) .map_err(|err| ExecutorError::Io(io::Error::other(err.to_string())))?; self.send_raw(&raw).await } pub async fn request( &self, request_id: RequestId, message: &T, label: &str, cancel: CancellationToken, ) -> Result where R: DeserializeOwned + Debug, T: Serialize + Sync, { let receiver = self.register(request_id).await; self.send(message).await?; await_response(receiver, label, cancel).await } async fn send_raw(&self, payload: &str) -> Result<(), ExecutorError> { let mut guard = self.stdin.lock().await; guard .write_all(payload.as_bytes()) .await .map_err(ExecutorError::Io)?; guard.write_all(b"\n").await.map_err(ExecutorError::Io)?; guard.flush().await.map_err(ExecutorError::Io)?; Ok(()) } } pub type PendingReceiver = oneshot::Receiver; pub async fn await_response( receiver: PendingReceiver, label: &str, cancel: CancellationToken, ) -> Result where R: DeserializeOwned + Debug, { let response = tokio::select! { _ = cancel.cancelled() => { return Err(ExecutorError::Io(io::Error::other(format!( "{label} request cancelled", )))); } result = receiver => result, }; match response { Ok(PendingResponse::Result(value)) => serde_json::from_value(value).map_err(|err| { ExecutorError::Io(io::Error::other(format!( "failed to decode {label} response: {err}", ))) }), Ok(PendingResponse::Error(error)) => Err(ExecutorError::Io(io::Error::other(format!( "{label} request failed: {}", error.error.message )))), Ok(PendingResponse::Shutdown) => Err(ExecutorError::Io(io::Error::other(format!( "server was shutdown while waiting for {label} response", )))), Err(_) => Err(ExecutorError::Io(io::Error::other(format!( "{label} request was dropped", )))), } } #[async_trait] pub trait JsonRpcCallbacks: Send + Sync { async fn on_request( &self, peer: &JsonRpcPeer, raw: &str, request: JSONRPCRequest, ) -> Result<(), ExecutorError>; async fn on_response( &self, peer: &JsonRpcPeer, raw: &str, response: &JSONRPCResponse, ) -> Result<(), ExecutorError>; async fn on_error( &self, peer: &JsonRpcPeer, raw: &str, error: &JSONRPCError, ) -> Result<(), ExecutorError>; async fn on_notification( &self, peer: &JsonRpcPeer, raw: &str, notification: JSONRPCNotification, ) -> Result; async fn on_non_json(&self, _raw: &str) -> Result<(), ExecutorError>; } ================================================ FILE: crates/executors/src/executors/codex/normalize_logs.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, sync::{Arc, LazyLock}, time::Duration, }; use codex_app_server_protocol::{ JSONRPCNotification, JSONRPCResponse, ServerNotification, ThreadForkResponse, ThreadStartResponse, }; use codex_protocol::{ items::TurnItem, openai_models::ReasoningEffort, plan_tool::{StepStatus, UpdatePlanArgs}, protocol::{ AgentMessageDeltaEvent, AgentMessageEvent, AgentReasoningDeltaEvent, AgentReasoningEvent, AgentReasoningSectionBreakEvent, ApplyPatchApprovalRequestEvent, BackgroundEventEvent, ErrorEvent, EventMsg, ExecApprovalRequestEvent, ExecCommandBeginEvent, ExecCommandEndEvent, ExecCommandOutputDeltaEvent, ExecOutputStream, ExitedReviewModeEvent, FileChange as CodexProtoFileChange, ItemCompletedEvent, ItemStartedEvent, McpInvocation, McpToolCallBeginEvent, McpToolCallEndEvent, ModelRerouteEvent, PatchApplyBeginEvent, PatchApplyEndEvent, PlanDeltaEvent, RequestUserInputEvent, StreamErrorEvent, ViewImageToolCallEvent, WarningEvent, WebSearchBeginEvent, WebSearchEndEvent, }, }; use futures::StreamExt; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use workspace_utils::{ approvals::{ApprovalStatus, QuestionStatus}, diff::normalize_unified_diff, msg_store::MsgStore, path::make_path_relative, }; use crate::{ approvals::ToolCallMetadata, logs::{ ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption, CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolResult, ToolResultValueType, ToolStatus, plain_text_processor::PlainTextLogProcessor, utils::{ ConversationPatch, EntryIndexProvider, patch::{add_normalized_entry, replace_normalized_entry, upsert_normalized_entry}, shell_command_parsing::{CommandCategory, unwrap_shell_command}, }, }, }; trait ToNormalizedEntry { fn to_normalized_entry(&self) -> NormalizedEntry; } trait ToNormalizedEntryOpt { fn to_normalized_entry_opt(&self) -> Option; } #[derive(Debug, Deserialize)] struct CodexNotificationParams { #[serde(rename = "msg")] msg: EventMsg, } #[derive(Default)] struct StreamingText { index: usize, content: String, } #[derive(Default)] struct CommandState { index: Option, command: String, stdout: String, stderr: String, formatted_output: Option, status: ToolStatus, exit_code: Option, awaiting_approval: bool, call_id: String, } impl ToNormalizedEntry for CommandState { fn to_normalized_entry(&self) -> NormalizedEntry { let content = self.command.to_string(); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "bash".to_string(), action_type: ActionType::CommandRun { command: unwrap_shell_command(&self.command).to_string(), result: Some(CommandRunResult { exit_status: self .exit_code .map(|code| CommandExitStatus::ExitCode { code }), output: if self.formatted_output.is_some() { self.formatted_output.clone() } else { build_command_output(Some(&self.stdout), Some(&self.stderr)) }, }), category: CommandCategory::from_command(&self.command), }, status: self.status.clone(), }, content, metadata: serde_json::to_value(ToolCallMetadata { tool_call_id: self.call_id.clone(), }) .ok(), } } } struct McpToolState { index: Option, invocation: McpInvocation, result: Option, status: ToolStatus, } impl ToNormalizedEntry for McpToolState { fn to_normalized_entry(&self) -> NormalizedEntry { let tool_name = format!("mcp:{}:{}", self.invocation.server, self.invocation.tool); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: tool_name.clone(), action_type: ActionType::Tool { tool_name, arguments: self.invocation.arguments.clone(), result: self.result.clone(), }, status: self.status.clone(), }, content: self.invocation.tool.clone(), metadata: None, } } } #[derive(Default)] struct WebSearchState { index: Option, query: Option, status: ToolStatus, } impl WebSearchState { fn new() -> Self { Default::default() } } impl ToNormalizedEntry for WebSearchState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "web_search".to_string(), action_type: ActionType::WebFetch { url: self.query.clone().unwrap_or_else(|| "...".to_string()), }, status: self.status.clone(), }, content: self .query .clone() .unwrap_or_else(|| "Web search".to_string()), metadata: None, } } } struct UserInputRequestState { index: Option, questions: Vec, content: String, status: ToolStatus, } impl ToNormalizedEntry for UserInputRequestState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "question".to_string(), action_type: ActionType::AskUserQuestion { questions: self.questions.clone(), }, status: self.status.clone(), }, content: self.content.clone(), metadata: None, } } } struct PlanState { index: Option, text: String, status: ToolStatus, } impl ToNormalizedEntry for PlanState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "plan".to_string(), action_type: ActionType::PlanPresentation { plan: self.text.clone(), }, status: self.status.clone(), }, content: "Plan".to_string(), metadata: None, } } } struct ReviewState { index: Option, description: String, status: ToolStatus, result: Option, } impl ReviewState { fn complete(&mut self, review_event: &ExitedReviewModeEvent, worktree_path: &str) { let result_text = match &review_event.review_output { Some(output) => { let mut sections = Vec::new(); sections.push(format!( "**Correctness:** {} | **Confidence:** {}", output.overall_correctness, output.overall_confidence_score, )); let explanation = output.overall_explanation.trim(); if !explanation.is_empty() { sections.push(explanation.to_string()); } if !output.findings.is_empty() { let mut lines = vec!["### Findings".to_string()]; for finding in &output.findings { let abs_path = finding.code_location.absolute_file_path.to_string_lossy(); let path = make_path_relative(&abs_path, worktree_path); let start = finding.code_location.line_range.start; let end = finding.code_location.line_range.end; lines.push(format!( "- **P{}** | **Confidence:** {} | {}", finding.priority, finding.confidence_score, finding.title, )); lines.push(format!(" `{path}:{start}-{end}`")); for body_line in finding.body.lines() { lines.push(format!(" {body_line}")); } } sections.push(lines.join("\n")); } if sections.is_empty() { "Review completed".to_string() } else { sections.join("\n\n") } } None => "Review completed".to_string(), }; self.status = ToolStatus::Success; self.result = Some(ToolResult::markdown(result_text)); } } impl ToNormalizedEntry for ReviewState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "Review".to_string(), action_type: ActionType::TaskCreate { description: self.description.clone(), subagent_type: Some("review".to_string()), result: self.result.clone(), }, status: self.status.clone(), }, content: String::new(), metadata: None, } } } #[derive(Default)] struct PatchState { entries: Vec, } struct PatchEntry { index: Option, path: String, changes: Vec, status: ToolStatus, awaiting_approval: bool, call_id: String, } impl ToNormalizedEntry for PatchEntry { fn to_normalized_entry(&self) -> NormalizedEntry { let content = self.path.clone(); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "edit".to_string(), action_type: ActionType::FileEdit { path: self.path.clone(), changes: self.changes.clone(), }, status: self.status.clone(), }, content, metadata: serde_json::to_value(ToolCallMetadata { tool_call_id: self.call_id.clone(), }) .ok(), } } } struct LogState { entry_index: EntryIndexProvider, assistant: Option, thinking: Option, commands: HashMap, mcp_tools: HashMap, patches: HashMap, web_searches: HashMap, user_input_requests: HashMap, plans: HashMap, review: Option, model_params: ModelParamsState, } struct ModelParamsState { index: Option, model: Option, reasoning_effort: Option, } enum StreamingTextKind { Assistant, Thinking, } impl LogState { fn new(entry_index: EntryIndexProvider) -> Self { Self { entry_index, assistant: None, thinking: None, commands: HashMap::new(), mcp_tools: HashMap::new(), patches: HashMap::new(), web_searches: HashMap::new(), user_input_requests: HashMap::new(), plans: HashMap::new(), review: None, model_params: ModelParamsState { index: None, model: None, reasoning_effort: None, }, } } fn streaming_text_update( &mut self, content: String, type_: StreamingTextKind, mode: UpdateMode, ) -> (NormalizedEntry, usize, bool) { let index_provider = &self.entry_index; let entry = match type_ { StreamingTextKind::Assistant => &mut self.assistant, StreamingTextKind::Thinking => &mut self.thinking, }; let is_new = entry.is_none(); let (content, index) = if entry.is_none() { let index = index_provider.next(); *entry = Some(StreamingText { index, content }); (&entry.as_ref().unwrap().content, index) } else { let streaming_state = entry.as_mut().unwrap(); match mode { UpdateMode::Append => streaming_state.content.push_str(&content), UpdateMode::Set => streaming_state.content = content, } (&streaming_state.content, streaming_state.index) }; let normalized_entry = NormalizedEntry { timestamp: None, entry_type: match type_ { StreamingTextKind::Assistant => NormalizedEntryType::AssistantMessage, StreamingTextKind::Thinking => NormalizedEntryType::Thinking, }, content: content.clone(), metadata: None, }; (normalized_entry, index, is_new) } fn streaming_text_append( &mut self, content: String, type_: StreamingTextKind, ) -> (NormalizedEntry, usize, bool) { self.streaming_text_update(content, type_, UpdateMode::Append) } fn streaming_text_set( &mut self, content: String, type_: StreamingTextKind, ) -> (NormalizedEntry, usize, bool) { self.streaming_text_update(content, type_, UpdateMode::Set) } fn assistant_message_append(&mut self, content: String) -> (NormalizedEntry, usize, bool) { self.streaming_text_append(content, StreamingTextKind::Assistant) } fn thinking_append(&mut self, content: String) -> (NormalizedEntry, usize, bool) { self.streaming_text_append(content, StreamingTextKind::Thinking) } fn assistant_message(&mut self, content: String) -> (NormalizedEntry, usize, bool) { self.streaming_text_set(content, StreamingTextKind::Assistant) } fn thinking(&mut self, content: String) -> (NormalizedEntry, usize, bool) { self.streaming_text_set(content, StreamingTextKind::Thinking) } fn update_tool_status( &mut self, call_id: &str, status: ToolStatus, clear_awaiting: bool, msg_store: &Arc, ) { if let Some(cmd) = self.commands.get_mut(call_id) { cmd.status = status.clone(); if clear_awaiting { cmd.awaiting_approval = false; } if let Some(index) = cmd.index { replace_normalized_entry(msg_store, index, cmd.to_normalized_entry()); } } else if let Some(mcp) = self.mcp_tools.get_mut(call_id) { mcp.status = status.clone(); if let Some(index) = mcp.index { replace_normalized_entry(msg_store, index, mcp.to_normalized_entry()); } } else if let Some(patch_state) = self.patches.get_mut(call_id) { for entry in &mut patch_state.entries { entry.status = status.clone(); if clear_awaiting { entry.awaiting_approval = false; } if let Some(index) = entry.index { replace_normalized_entry(msg_store, index, entry.to_normalized_entry()); } } } else if let Some(input_state) = self.user_input_requests.get_mut(call_id) { input_state.status = status; if let Some(index) = input_state.index { replace_normalized_entry(msg_store, index, input_state.to_normalized_entry()); } } else if let Some(plan_state) = self.plans.get_mut(call_id) { plan_state.status = status; if let Some(index) = plan_state.index { replace_normalized_entry(msg_store, index, plan_state.to_normalized_entry()); } } } } enum UpdateMode { Append, Set, } fn normalize_file_changes( worktree_path: &str, changes: &HashMap, ) -> Vec<(String, Vec)> { changes .iter() .map(|(path, change)| { let path_str = path.to_string_lossy(); let relative = make_path_relative(path_str.as_ref(), worktree_path); let file_changes = match change { CodexProtoFileChange::Add { content } => vec![FileChange::Write { content: content.clone(), }], CodexProtoFileChange::Delete { .. } => vec![FileChange::Delete], CodexProtoFileChange::Update { unified_diff, move_path, } => { let mut edits = Vec::new(); if let Some(dest) = move_path { let dest_rel = make_path_relative(dest.to_string_lossy().as_ref(), worktree_path); edits.push(FileChange::Rename { new_path: dest_rel }); } let diff = normalize_unified_diff(&relative, unified_diff); edits.push(FileChange::Edit { unified_diff: diff, has_line_numbers: true, }); edits } }; (relative, file_changes) }) .collect() } fn format_todo_status(status: &StepStatus) -> String { match status { StepStatus::Pending => "pending", StepStatus::InProgress => "in_progress", StepStatus::Completed => "completed", } .to_string() } /// Stderr patterns from codex internals that should be suppressed from user-visible logs. const SUPPRESSED_STDERR_PATTERNS: &[&str] = &[ // Codex unconditionally logs this error during its SQLite migration when a rollout file // exists on disk but isn't indexed in the state DB — even when the Sqlite feature flag is // disabled (which is the default). See: https://github.com/openai/codex/commit/c38a5958 "state db missing rollout path for", ]; /// Codex-specific stderr normalizer that filters noisy internal messages. fn normalize_codex_stderr_logs( msg_store: Arc, entry_index_provider: EntryIndexProvider, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stderr = msg_store.stderr_chunked_stream(); let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(|content: String| NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: strip_ansi_escapes::strip_str(&content), metadata: None, }) .time_gap(Duration::from_secs(2)) .index_provider(entry_index_provider) .transform_lines(Box::new(|lines: &mut Vec| { lines.retain(|line| { !SUPPRESSED_STDERR_PATTERNS .iter() .any(|pattern| line.contains(pattern)) }); })) .build(); while let Some(Ok(chunk)) = stderr.next().await { for patch in processor.process(chunk) { msg_store.push_patch(patch); } } }) } pub fn normalize_logs( msg_store: Arc, worktree_path: &Path, ) -> Vec> { let entry_index = EntryIndexProvider::start_from(&msg_store); let h1 = normalize_codex_stderr_logs(msg_store.clone(), entry_index.clone()); let worktree_path_str = worktree_path.to_string_lossy().to_string(); let h2 = tokio::spawn(async move { let mut state = LogState::new(entry_index.clone()); let mut stdout_lines = msg_store.stdout_lines_stream(); while let Some(Ok(line)) = stdout_lines.next().await { if let Ok(error) = serde_json::from_str::(&line) { add_normalized_entry(&msg_store, &entry_index, error.to_normalized_entry()); continue; } if let Ok(approval) = serde_json::from_str::(&line) { match &approval { Approval::ApprovalRequested { call_id, approval_id, .. } => { let pending_status = ToolStatus::PendingApproval { approval_id: approval_id.clone(), }; state.update_tool_status(call_id, pending_status, false, &msg_store); } Approval::ApprovalResponse { call_id, approval_status, .. } => { if let Some(status) = ToolStatus::from_approval_status(approval_status) { state.update_tool_status(call_id, status, true, &msg_store); } if let Some(entry) = approval.to_normalized_entry_opt() { add_normalized_entry(&msg_store, &entry_index, entry); } } Approval::QuestionResponse { call_id, question_status, } => { let status = ToolStatus::from_question_status(question_status); state.update_tool_status(call_id, status, true, &msg_store); if let Some(entry) = approval.to_normalized_entry_opt() { add_normalized_entry(&msg_store, &entry_index, entry); } } } continue; } if let Ok(response) = serde_json::from_str::(&line) { handle_jsonrpc_response( response, &msg_store, &entry_index, &mut state.model_params, ); continue; } if let Ok(server_notification) = serde_json::from_str::(&line) { if let ServerNotification::ThreadStarted(n) = server_notification { msg_store.push_session_id(n.thread.id); } continue; } else if let Some(session_id) = line .strip_prefix(r#"{"method":"sessionConfigured","params":{"sessionId":""#) .and_then(|suffix| SESSION_ID.captures(suffix).and_then(|caps| caps.get(1))) { // Best-effort extraction of session ID from logs in case the JSON parsing fails. // This could happen if the line is truncated due to size limits because it includes the full session history. msg_store.push_session_id(session_id.as_str().to_string()); continue; } let notification: JSONRPCNotification = match serde_json::from_str(&line) { Ok(value) => value, Err(_) => continue, }; if !notification.method.starts_with("codex/event") { continue; } let Some(params) = notification .params .and_then(|p| serde_json::from_value::(p).ok()) else { continue; }; let event = params.msg; match event { EventMsg::SessionConfigured(payload) => { msg_store.push_session_id(payload.session_id.to_string()); handle_model_params( Some(payload.model), payload.reasoning_effort, &msg_store, &entry_index, &mut state.model_params, ); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { state.thinking = None; let (entry, index, is_new) = state.assistant_message_append(delta); upsert_normalized_entry(&msg_store, index, entry, is_new); } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { state.assistant = None; let (entry, index, is_new) = state.thinking_append(delta); upsert_normalized_entry(&msg_store, index, entry, is_new); } EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { state.thinking = None; let (entry, index, is_new) = state.assistant_message(message); upsert_normalized_entry(&msg_store, index, entry, is_new); state.assistant = None; } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { state.assistant = None; let (entry, index, is_new) = state.thinking(text); upsert_normalized_entry(&msg_store, index, entry, is_new); state.thinking = None; } EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { item_id: _, summary_index: _, }) => { state.assistant = None; state.thinking = None; } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id, turn_id: _, command, cwd: _, reason, parsed_cmd: _, proposed_execpolicy_amendment: _, .. }) => { state.assistant = None; state.thinking = None; let command_text = if command.is_empty() { reason .filter(|r| !r.is_empty()) .unwrap_or_else(|| "command execution".to_string()) } else { command.join(" ") }; let command_state = state.commands.entry(call_id.clone()).or_default(); if command_state.command.is_empty() { command_state.command = command_text; } command_state.awaiting_approval = true; command_state.call_id = call_id.clone(); if let Some(index) = command_state.index { replace_normalized_entry( &msg_store, index, command_state.to_normalized_entry(), ); } else { let index = add_normalized_entry( &msg_store, &entry_index, command_state.to_normalized_entry(), ); command_state.index = Some(index); } } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, turn_id: _, changes, reason: _, grant_root: _, }) => { state.assistant = None; state.thinking = None; let normalized = normalize_file_changes(&worktree_path_str, &changes); let patch_state = state.patches.entry(call_id.clone()).or_default(); // Update existing entries in place to keep them in MsgStore let normalized_len = normalized.len(); let mut iter = normalized.into_iter(); for entry in &mut patch_state.entries { if let Some((path, file_changes)) = iter.next() { entry.path = path; entry.changes = file_changes; entry.awaiting_approval = true; if let Some(index) = entry.index { replace_normalized_entry( &msg_store, index, entry.to_normalized_entry(), ); } else { let index = add_normalized_entry( &msg_store, &entry_index, entry.to_normalized_entry(), ); entry.index = Some(index); } } } // Remove stale entries if new changes have fewer files if normalized_len < patch_state.entries.len() { for entry in patch_state.entries.drain(normalized_len..) { if let Some(index) = entry.index { msg_store.push_patch(ConversationPatch::remove(index)); } } } // Add new entries if changes have more files for (path, file_changes) in iter { let mut entry = PatchEntry { index: None, path, changes: file_changes, status: ToolStatus::Created, awaiting_approval: true, call_id: call_id.clone(), }; let index = add_normalized_entry( &msg_store, &entry_index, entry.to_normalized_entry(), ); entry.index = Some(index); patch_state.entries.push(entry); } } EventMsg::ExecCommandBegin(ExecCommandBeginEvent { call_id, turn_id: _, command, cwd: _, parsed_cmd: _, source: _, interaction_input: _, process_id: _, }) => { state.assistant = None; state.thinking = None; let command_text = command.join(" "); if command_text.is_empty() { continue; } state.commands.insert( call_id.clone(), CommandState { index: None, command: command_text, stdout: String::new(), stderr: String::new(), formatted_output: None, status: ToolStatus::Created, exit_code: None, awaiting_approval: false, call_id: call_id.clone(), }, ); let command_state = state.commands.get_mut(&call_id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index, command_state.to_normalized_entry(), ); command_state.index = Some(index) } EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { call_id, stream, chunk, }) => { if let Some(command_state) = state.commands.get_mut(&call_id) { let chunk = String::from_utf8_lossy(&chunk); if chunk.is_empty() { continue; } match stream { ExecOutputStream::Stdout => command_state.stdout.push_str(&chunk), ExecOutputStream::Stderr => command_state.stderr.push_str(&chunk), } let Some(index) = command_state.index else { tracing::error!("missing entry index for existing command state"); continue; }; replace_normalized_entry( &msg_store, index, command_state.to_normalized_entry(), ); } } EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id, turn_id: _, command: _, cwd: _, parsed_cmd: _, source: _, interaction_input: _, stdout: _, stderr: _, aggregated_output: _, exit_code, duration: _, formatted_output, process_id: _, .. }) => { if let Some(mut command_state) = state.commands.remove(&call_id) { command_state.formatted_output = Some(formatted_output); command_state.exit_code = Some(exit_code); command_state.awaiting_approval = false; command_state.status = if exit_code == 0 { ToolStatus::Success } else { ToolStatus::Failed }; let Some(index) = command_state.index else { tracing::error!("missing entry index for existing command state"); continue; }; replace_normalized_entry( &msg_store, index, command_state.to_normalized_entry(), ); } } EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("Background event: {message}"), metadata: None, }, ); } EventMsg::StreamError(StreamErrorEvent { message, codex_error_info, .. }) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: format!("Stream error: {message} {codex_error_info:?}"), metadata: None, }, ); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id, invocation, }) => { state.assistant = None; state.thinking = None; state.mcp_tools.insert( call_id.clone(), McpToolState { index: None, invocation, result: None, status: ToolStatus::Created, }, ); let mcp_tool_state = state.mcp_tools.get_mut(&call_id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index, mcp_tool_state.to_normalized_entry(), ); mcp_tool_state.index = Some(index); } EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id, result, .. }) => { if let Some(mut mcp_tool_state) = state.mcp_tools.remove(&call_id) { match result { Ok(value) => { mcp_tool_state.status = if value.is_error.unwrap_or(false) { ToolStatus::Failed } else { ToolStatus::Success }; if value.content.iter().all(|block| { block.get("type").and_then(|t| t.as_str()) == Some("text") }) { mcp_tool_state.result = Some(ToolResult { r#type: ToolResultValueType::Markdown, value: Value::String( value .content .iter() .filter_map(|block| { block .get("text") .and_then(|t| t.as_str()) .map(|s| s.to_owned()) }) .collect::>() .join("\n"), ), }); } else { mcp_tool_state.result = Some(ToolResult { r#type: ToolResultValueType::Json, value: value.structured_content.unwrap_or_else(|| { serde_json::to_value(value.content).unwrap_or_default() }), }); } } Err(err) => { mcp_tool_state.status = ToolStatus::Failed; mcp_tool_state.result = Some(ToolResult { r#type: ToolResultValueType::Markdown, value: Value::String(err), }); } }; let Some(index) = mcp_tool_state.index else { tracing::error!("missing entry index for existing mcp tool state"); continue; }; replace_normalized_entry( &msg_store, index, mcp_tool_state.to_normalized_entry(), ); } } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id, changes, .. }) => { state.assistant = None; state.thinking = None; let normalized = normalize_file_changes(&worktree_path_str, &changes); if let Some(patch_state) = state.patches.get_mut(&call_id) { let mut iter = normalized.into_iter(); for entry in &mut patch_state.entries { if let Some((path, file_changes)) = iter.next() { entry.path = path; entry.changes = file_changes; } entry.status = ToolStatus::Created; entry.awaiting_approval = false; if let Some(index) = entry.index { replace_normalized_entry( &msg_store, index, entry.to_normalized_entry(), ); } else { let index = add_normalized_entry( &msg_store, &entry_index, entry.to_normalized_entry(), ); entry.index = Some(index); } } for (path, file_changes) in iter { let mut entry = PatchEntry { index: None, path, changes: file_changes, status: ToolStatus::Created, awaiting_approval: false, call_id: call_id.clone(), }; let index = add_normalized_entry( &msg_store, &entry_index, entry.to_normalized_entry(), ); entry.index = Some(index); patch_state.entries.push(entry); } } else { let mut patch_state = PatchState::default(); for (path, file_changes) in normalized { patch_state.entries.push(PatchEntry { index: None, path, changes: file_changes, status: ToolStatus::Created, awaiting_approval: false, call_id: call_id.clone(), }); let patch_entry = patch_state.entries.last_mut().unwrap(); let index = add_normalized_entry( &msg_store, &entry_index, patch_entry.to_normalized_entry(), ); patch_entry.index = Some(index); } state.patches.insert(call_id, patch_state); } } EventMsg::PatchApplyEnd(PatchApplyEndEvent { call_id, stdout: _, stderr: _, success, .. }) => { if let Some(patch_state) = state.patches.remove(&call_id) { let status = if success { ToolStatus::Success } else { ToolStatus::Failed }; for mut entry in patch_state.entries { entry.status = status.clone(); let Some(index) = entry.index else { tracing::error!("missing entry index for existing patch entry"); continue; }; replace_normalized_entry( &msg_store, index, entry.to_normalized_entry(), ); } } } EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id }) => { state.assistant = None; state.thinking = None; state .web_searches .insert(call_id.clone(), WebSearchState::new()); let web_search_state = state.web_searches.get_mut(&call_id).unwrap(); let normalized_entry = web_search_state.to_normalized_entry(); let index = add_normalized_entry(&msg_store, &entry_index, normalized_entry); web_search_state.index = Some(index); } EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query, .. }) => { state.assistant = None; state.thinking = None; if let Some(mut entry) = state.web_searches.remove(&call_id) { entry.status = ToolStatus::Success; entry.query = Some(query.clone()); let normalized_entry = entry.to_normalized_entry(); let Some(index) = entry.index else { tracing::error!("missing entry index for existing websearch entry"); continue; }; replace_normalized_entry(&msg_store, index, normalized_entry); } } EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: _, path }) => { state.assistant = None; state.thinking = None; let path_str = path.to_string_lossy().to_string(); let relative_path = make_path_relative(&path_str, &worktree_path_str); add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "view_image".to_string(), action_type: ActionType::FileRead { path: relative_path.clone(), }, status: ToolStatus::Success, }, content: relative_path.to_string(), metadata: None, }, ); } EventMsg::PlanUpdate(UpdatePlanArgs { plan, explanation }) => { let todos: Vec = plan .iter() .map(|item| TodoItem { content: item.step.clone(), status: format_todo_status(&item.status), priority: None, }) .collect(); let explanation = explanation .as_ref() .map(|text| text.trim()) .filter(|text| !text.is_empty()) .map(|text| text.to_string()); let content = explanation.clone().unwrap_or_else(|| { if todos.is_empty() { "Plan updated".to_string() } else { format!("Plan updated ({} steps)", todos.len()) } }); add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "plan".to_string(), action_type: ActionType::TodoManagement { todos, operation: "update".to_string(), }, status: ToolStatus::Success, }, content, metadata: None, }, ); } EventMsg::Warning(WarningEvent { message }) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: message, metadata: None, }, ); } EventMsg::ModelReroute(ModelRerouteEvent { from_model, to_model, .. }) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!( "warning: model rerouted from {from_model} to {to_model}" ), metadata: None, }, ); } EventMsg::Error(ErrorEvent { message, codex_error_info, }) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: format!("Error: {message} {codex_error_info:?}"), metadata: None, }, ); } EventMsg::TokenCount(payload) => { if let Some(info) = payload.info { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::TokenUsageInfo( crate::logs::TokenUsageInfo { total_tokens: info.last_token_usage.total_tokens as u32, model_context_window: info .model_context_window .unwrap_or_default() as u32, }, ), content: format!( "Tokens used: {} / Context window: {}", info.last_token_usage.total_tokens, info.model_context_window.unwrap_or_default() ), metadata: None, }, ); } } EventMsg::EnteredReviewMode(review_request) => { let mut review_state = ReviewState { index: None, description: review_request .user_facing_hint .unwrap_or_else(|| "Reviewing code...".to_string()), status: ToolStatus::Created, result: None, }; let index = add_normalized_entry( &msg_store, &entry_index, review_state.to_normalized_entry(), ); review_state.index = Some(index); state.review = Some(review_state); } EventMsg::ExitedReviewMode(review_event) => { if let Some(mut review_state) = state.review.take() { review_state.complete(&review_event, &worktree_path_str); if let Some(index) = review_state.index { replace_normalized_entry( &msg_store, index, review_state.to_normalized_entry(), ); } } } EventMsg::RequestUserInput(RequestUserInputEvent { call_id, turn_id: _, questions: event_questions, }) => { state.assistant = None; state.thinking = None; if call_id.is_empty() { continue; } let questions: Vec = event_questions .iter() .map(|q| AskUserQuestionItem { question: q.question.clone(), header: q.header.clone(), options: q .options .as_deref() .unwrap_or(&[]) .iter() .map(|o| AskUserQuestionOption { label: o.label.clone(), description: o.description.clone(), }) .collect(), multi_select: false, }) .collect(); let content = if questions.len() == 1 { questions[0].question.clone() } else { format!("{} questions", questions.len()) }; let index = entry_index.next(); let tool_state = UserInputRequestState { index: Some(index), questions, content, status: ToolStatus::Created, }; upsert_normalized_entry( &msg_store, index, tool_state.to_normalized_entry(), true, ); state.user_input_requests.insert(call_id, tool_state); } EventMsg::PlanDelta(PlanDeltaEvent { delta, item_id, .. }) => { state.thinking = None; if let Some(plan_state) = state.plans.get_mut(&item_id) { plan_state.text.push_str(&delta); if let Some(index) = plan_state.index { replace_normalized_entry( &msg_store, index, plan_state.to_normalized_entry(), ); } } else { // Backward compat: if no plan state, treat as assistant text let (entry, index, is_new) = state.assistant_message_append(delta); upsert_normalized_entry(&msg_store, index, entry, is_new); } } EventMsg::ContextCompacted(..) => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: "Context compacted".to_string(), metadata: None, }, ); } EventMsg::ItemStarted(ItemStartedEvent { item: TurnItem::Plan(ref plan_item), .. }) => { state.assistant = None; state.thinking = None; let mut plan_state = PlanState { index: None, text: String::new(), status: ToolStatus::Created, }; let index = add_normalized_entry( &msg_store, &entry_index, plan_state.to_normalized_entry(), ); plan_state.index = Some(index); state.plans.insert(plan_item.id.clone(), plan_state); } EventMsg::ItemCompleted(ItemCompletedEvent { item: TurnItem::Plan(ref plan_item), .. }) => { if let Some(plan_state) = state.plans.get_mut(&plan_item.id) { plan_state.text = plan_item.text.clone(); if let Some(index) = plan_state.index { replace_normalized_entry( &msg_store, index, plan_state.to_normalized_entry(), ); } } } EventMsg::AgentReasoningRawContent(..) | EventMsg::AgentReasoningRawContentDelta(..) | EventMsg::ThreadRolledBack(..) | EventMsg::TurnStarted(..) | EventMsg::UserMessage(..) | EventMsg::TurnDiff(..) | EventMsg::GetHistoryEntryResponse(..) | EventMsg::McpListToolsResponse(..) | EventMsg::McpStartupComplete(..) | EventMsg::McpStartupUpdate(..) | EventMsg::DeprecationNotice(..) | EventMsg::UndoCompleted(..) | EventMsg::UndoStarted(..) | EventMsg::RawResponseItem(..) | EventMsg::ItemStarted(..) | EventMsg::ItemCompleted(..) | EventMsg::AgentMessageContentDelta(..) | EventMsg::ReasoningContentDelta(..) | EventMsg::ReasoningRawContentDelta(..) | EventMsg::ListCustomPromptsResponse(..) | EventMsg::ListSkillsResponse(..) | EventMsg::SkillsUpdateAvailable | EventMsg::TurnAborted(..) | EventMsg::ShutdownComplete | EventMsg::TerminalInteraction(..) | EventMsg::ElicitationRequest(..) | EventMsg::TurnComplete(..) | EventMsg::CollabAgentSpawnBegin(..) | EventMsg::CollabAgentSpawnEnd(..) | EventMsg::CollabAgentInteractionBegin(..) | EventMsg::CollabAgentInteractionEnd(..) | EventMsg::CollabWaitingBegin(..) | EventMsg::CollabWaitingEnd(..) | EventMsg::CollabCloseBegin(..) | EventMsg::CollabCloseEnd(..) | EventMsg::CollabResumeBegin(..) | EventMsg::CollabResumeEnd(..) | EventMsg::ThreadNameUpdated(..) | EventMsg::DynamicToolCallRequest(..) | EventMsg::DynamicToolCallResponse(..) | EventMsg::ListRemoteSkillsResponse(..) | EventMsg::RemoteSkillDownloaded(..) | EventMsg::RealtimeConversationStarted(..) | EventMsg::RealtimeConversationRealtime(..) | EventMsg::RealtimeConversationClosed(..) | EventMsg::ImageGenerationBegin(..) | EventMsg::ImageGenerationEnd(..) | EventMsg::RequestPermissions(..) | EventMsg::HookCompleted(..) | EventMsg::HookStarted(..) => {} } } }); vec![h1, h2] } fn handle_jsonrpc_response( response: JSONRPCResponse, msg_store: &Arc, entry_index: &EntryIndexProvider, model_params: &mut ModelParamsState, ) { if let Ok(resp) = serde_json::from_value::(response.result.clone()) { msg_store.push_session_id(resp.thread.id); handle_model_params( Some(resp.model), resp.reasoning_effort, msg_store, entry_index, model_params, ); return; } if let Ok(resp) = serde_json::from_value::(response.result.clone()) { msg_store.push_session_id(resp.thread.id); handle_model_params( Some(resp.model), resp.reasoning_effort, msg_store, entry_index, model_params, ); } } fn handle_model_params( model: Option, reasoning_effort: Option, msg_store: &Arc, entry_index: &EntryIndexProvider, state: &mut ModelParamsState, ) { if let Some(model) = model { state.model = Some(model); } if let Some(reasoning_effort) = reasoning_effort { state.reasoning_effort = Some(reasoning_effort); } let mut params = vec![]; if let Some(model) = &state.model { params.push(format!("model: {model}")); } if let Some(reasoning_effort) = &state.reasoning_effort { params.push(format!("reasoning effort: {reasoning_effort}")); } if params.is_empty() { return; } let is_new = state.index.is_none(); let index = *state.index.get_or_insert_with(|| entry_index.next()); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: params.join(" "), metadata: None, }; upsert_normalized_entry(msg_store, index, entry, is_new); } fn build_command_output(stdout: Option<&str>, stderr: Option<&str>) -> Option { let mut sections = Vec::new(); if let Some(out) = stdout { let cleaned = out.trim(); if !cleaned.is_empty() { sections.push(format!("stdout:\n{cleaned}")); } } if let Some(err) = stderr { let cleaned = err.trim(); if !cleaned.is_empty() { sections.push(format!("stderr:\n{cleaned}")); } } if sections.is_empty() { None } else { Some(sections.join("\n\n")) } } static SESSION_ID: LazyLock = LazyLock::new(|| { Regex::new(r#"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"#) .expect("valid regex") }); #[derive(Serialize, Deserialize, Debug)] pub enum Error { LaunchError { error: String }, AuthRequired { error: String }, } impl Error { pub fn launch_error(error: String) -> Self { Self::LaunchError { error } } pub fn auth_required(error: String) -> Self { Self::AuthRequired { error } } pub fn raw(&self) -> String { serde_json::to_string(self).unwrap_or_default() } } impl ToNormalizedEntry for Error { fn to_normalized_entry(&self) -> NormalizedEntry { match self { Error::LaunchError { error } => NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: error.clone(), metadata: None, }, Error::AuthRequired { error } => NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::SetupRequired, }, content: error.clone(), metadata: None, }, } } } #[derive(Serialize, Deserialize, Debug)] pub enum Approval { ApprovalRequested { call_id: String, tool_name: String, approval_id: String, }, ApprovalResponse { call_id: String, tool_name: String, approval_status: ApprovalStatus, }, QuestionResponse { call_id: String, question_status: QuestionStatus, }, } impl Approval { pub fn approval_requested(call_id: String, tool_name: String, approval_id: String) -> Self { Self::ApprovalRequested { call_id, tool_name, approval_id, } } pub fn approval_response( call_id: String, tool_name: String, approval_status: ApprovalStatus, ) -> Self { Self::ApprovalResponse { call_id, tool_name, approval_status, } } pub fn question_response(call_id: String, question_status: QuestionStatus) -> Self { Self::QuestionResponse { call_id, question_status, } } pub fn raw(&self) -> String { serde_json::to_string(self).unwrap_or_default() } pub fn display_tool_name(&self) -> String { match self { Self::ApprovalRequested { tool_name, .. } | Self::ApprovalResponse { tool_name, .. } => match tool_name.as_str() { "codex.exec_command" => "Exec Command".to_string(), "codex.apply_patch" => "Edit".to_string(), "codex.question" => "Question".to_string(), "codex.plan" => "Plan".to_string(), other => other.to_string(), }, Self::QuestionResponse { .. } => "Question".to_string(), } } } impl ToNormalizedEntryOpt for Approval { fn to_normalized_entry_opt(&self) -> Option { let approval_status = match self { Self::ApprovalResponse { approval_status, .. } => approval_status, Self::QuestionResponse { question_status, .. } => { return match question_status { QuestionStatus::Answered { answers } => { let qa_pairs: Vec = answers .iter() .map(|qa| AnsweredQuestion { question: qa.question.clone(), answer: qa.answer.clone(), }) .collect(); Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserAnsweredQuestions { answers: qa_pairs, }, content: format!( "Answered {} question{}", answers.len(), if answers.len() != 1 { "s" } else { "" } ), metadata: None, }) } QuestionStatus::TimedOut => None, }; } Self::ApprovalRequested { .. } => return None, }; let tool_name = self.display_tool_name(); match approval_status { ApprovalStatus::Pending | ApprovalStatus::Approved => None, ApprovalStatus::Denied { reason } => Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserFeedback { denied_tool: tool_name.clone(), }, content: reason .clone() .unwrap_or_else(|| "User denied this tool use request".to_string()) .trim() .to_string(), metadata: None, }), ApprovalStatus::TimedOut => Some(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: format!("Approval timed out for tool {tool_name}"), metadata: None, }), } } } ================================================ FILE: crates/executors/src/executors/codex/review.rs ================================================ use std::sync::Arc; use codex_app_server_protocol::{ReviewTarget, ThreadStartParams}; use super::{client::AppServerClient, fork_params_from}; use crate::executors::ExecutorError; pub async fn launch_codex_review( thread_start_params: ThreadStartParams, resume_session: Option, review_target: ReviewTarget, client: Arc, ) -> Result<(), ExecutorError> { let account = client.get_account().await?; if account.requires_openai_auth && account.account.is_none() { return Err(ExecutorError::AuthRequired( "Codex authentication required".to_string(), )); } let thread_id = match resume_session { Some(session_id) => { let response = client .thread_fork(fork_params_from(session_id, thread_start_params)) .await?; tracing::debug!( "forked thread for review, new thread_id={}", response.thread.id ); response.thread.id } None => { let response = client.thread_start(thread_start_params).await?; response.thread.id } }; client.register_session(&thread_id).await?; client.start_review(thread_id, review_target).await?; Ok(()) } ================================================ FILE: crates/executors/src/executors/codex/slash_commands.rs ================================================ use std::path::{Path, PathBuf}; use codex_app_server_protocol::{ConfigEdit, JSONRPCNotification, MergeStrategy}; use codex_protocol::{ config_types::ServiceTier, protocol::{AgentMessageEvent, ErrorEvent, EventMsg}, }; use serde_json::json; use super::{ Codex, client::{AppServerClient, LogWriter}, codex_home, fork_params_from, resolve_model, }; use crate::{ env::ExecutionEnv, executors::{ ExecutorError, ExecutorExitResult, SpawnedChild, utils::{SlashCommandCall, parse_slash_command}, }, stdout_dup::spawn_local_output_process, }; const CODEX_INIT_PROMPT: &str = include_str!("init_prompt.md"); const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md"; #[derive(Debug, Clone)] pub enum CodexSlashCommand { Init, Compact { instructions: Option }, Status, Mcp, Fast { enable: Option }, } impl CodexSlashCommand { pub fn parse(prompt: &str) -> Option { let cmd: SlashCommandCall<'_> = parse_slash_command(prompt)?; match cmd.name.as_str() { "init" => Some(Self::Init), "compact" => Some(Self::Compact { instructions: if cmd.arguments.is_empty() { None } else { Some(cmd.arguments.to_string()) }, }), "status" => Some(Self::Status), "mcp" => Some(Self::Mcp), "fast" => Some(Self::Fast { enable: match cmd.arguments.trim() { "on" | "true" | "1" | "yes" | "enable" => Some(true), "off" | "false" | "0" | "no" | "disable" => Some(false), _ => None, }, }), _ => None, } } } impl Codex { pub async fn spawn_slash_command( &self, current_dir: &Path, prompt: &str, session_id: Option<&str>, env: &ExecutionEnv, ) -> Result { if let Some(command) = CodexSlashCommand::parse(prompt) { return match command { CodexSlashCommand::Init => { let init_target = current_dir.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { let message = format!( "`{DEFAULT_PROJECT_DOC_FILENAME}` already exists. Skipping `/init` to avoid overwriting it." ); self.return_static_reply(current_dir, Ok(message)).await } else { self.spawn_agent_with_prompt( current_dir, CODEX_INIT_PROMPT, session_id, env, ) .await } } CodexSlashCommand::Compact { .. } => match session_id { Some(_) => { self.handle_app_server_slash_command(current_dir, command, session_id, env) .await } None => { self.return_static_reply( current_dir, Ok("_No active session to compact._".to_string()), ) .await } }, CodexSlashCommand::Status => { self.handle_app_server_slash_command(current_dir, command, session_id, env) .await } CodexSlashCommand::Mcp => { self.handle_app_server_slash_command(current_dir, command, None, env) .await } CodexSlashCommand::Fast { .. } => { self.handle_app_server_slash_command(current_dir, command, session_id, env) .await } }; } self.spawn_agent_with_prompt(current_dir, prompt, session_id, env) .await } async fn spawn_agent_with_prompt( &self, current_dir: &Path, prompt: &str, session_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let command_parts = match session_id { Some(_) => self.build_command_builder()?.build_follow_up(&[])?, None => self.build_command_builder()?.build_initial()?, }; let combined_prompt = self.append_prompt.combine_prompt(prompt); let action = super::CodexSessionAction::Chat { prompt: combined_prompt, }; self.spawn_inner(current_dir, command_parts, action, session_id, env) .await } // Handle slash commands that require interaction with the app server async fn handle_app_server_slash_command( &self, current_dir: &Path, command: CodexSlashCommand, session_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let command_parts = self.build_command_builder()?.build_initial()?; let session_id = session_id.map(|s| s.to_string()); let (_, session_fast) = resolve_model(self.model.as_deref()); let thread_start_params = self.build_thread_start_params(current_dir); self.spawn_app_server( current_dir, command_parts, env, move |client, exit_signal_tx| async move { match command { CodexSlashCommand::Compact { .. } => { let old_thread_id = session_id.ok_or_else(|| { ExecutorError::Io(std::io::Error::other("No active session to compact")) })?; let fork_response = client .thread_fork(fork_params_from(old_thread_id, thread_start_params)) .await?; let thread_id = fork_response.thread.id; tracing::debug!("forked thread for compact, new thread_id={thread_id}"); client.thread_compact_start(thread_id).await?; } CodexSlashCommand::Status => { let message = fetch_status_message(&client, session_id.as_deref(), session_fast) .await?; log_event_raw(client.log_writer(), message).await?; exit_signal_tx .send_exit_signal(ExecutorExitResult::Success) .await; } CodexSlashCommand::Mcp => { let message = fetch_mcp_status_message(&client).await?; log_event_raw(client.log_writer(), message).await?; exit_signal_tx .send_exit_signal(ExecutorExitResult::Success) .await; } CodexSlashCommand::Fast { enable } => { // Read current config to support toggle let current_is_fast = client .config_read(None) .await .ok() .and_then(|r| r.config.service_tier) .map(|t| matches!(t, ServiceTier::Fast)) .unwrap_or(false); let want_fast = match enable { Some(v) => v, None => !current_is_fast, // toggle }; // Persist service_tier to codex config via config/batchWrite let config_value = if want_fast { json!("fast") } else { json!(null) }; let _ = client .config_batch_write(vec![ConfigEdit { key_path: "service_tier".to_string(), value: config_value, merge_strategy: MergeStrategy::Replace, }]) .await; // Fork current session with new tier if one is active if let Some(old_thread_id) = session_id { let service_tier = if want_fast { Some(Some(ServiceTier::Fast)) } else { Some(None) }; let mut fork_params = fork_params_from(old_thread_id, thread_start_params); fork_params.service_tier = service_tier; let _ = client.thread_fork(fork_params).await; } let message = if want_fast { "**Fast mode enabled.** Inference runs at higher speed (2× plan usage)." .to_string() } else { "**Fast mode disabled.**".to_string() }; log_event_raw(client.log_writer(), message).await?; exit_signal_tx .send_exit_signal(ExecutorExitResult::Success) .await; } _ => { return Err(ExecutorError::Io(std::io::Error::other( "Unsupported Codex slash command", ))); } } Ok(()) }, ) .await } pub async fn return_static_reply( &self, current_dir: &Path, message: Result, ) -> Result { self.spawn_static_reply_helper( current_dir, vec![match message { Ok(message) => EventMsg::AgentMessage(AgentMessageEvent { message, phase: None, }), Err(message) => EventMsg::Error(ErrorEvent { message, codex_error_info: None, }), }], ) .await } // Helper to spawn a process whose sole purpose is to channel back a static reply pub async fn spawn_static_reply_helper( &self, _current_dir: &Path, events: Vec, ) -> Result { let (mut spawned, writer) = spawn_local_output_process()?; let log_writer = LogWriter::new(writer); let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { let mut exit_result = ExecutorExitResult::Success; for event in events { if let Err(err) = log_event_notification(&log_writer, event).await { tracing::error!("Failed to emit slash command output: {err}"); exit_result = ExecutorExitResult::Failure; break; } } let _ = exit_signal_tx.send(exit_result); }); spawned.exit_signal = Some(exit_signal_rx); Ok(spawned) } } pub async fn log_event_notification( log_writer: &LogWriter, event: EventMsg, ) -> Result<(), ExecutorError> { let event = match event { EventMsg::SessionConfigured(mut configured) => { configured.initial_messages = None; EventMsg::SessionConfigured(configured) } other => other, }; let notification = JSONRPCNotification { method: "codex/event".to_string(), params: Some(json!({ "msg": event })), }; let raw = serde_json::to_string(¬ification) .map_err(|err| ExecutorError::Io(std::io::Error::other(err.to_string())))?; log_writer.log_raw(&raw).await } pub async fn log_event_raw(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> { log_event_notification( log_writer, EventMsg::AgentMessage(AgentMessageEvent { message, phase: None, }), ) .await } async fn fetch_status_message( client: &AppServerClient, thread_id: Option<&str>, session_fast: bool, ) -> Result { let mut lines = vec!["# Session Status\n".to_string()]; let rollout = match thread_id { Some(tid) => read_rollout_data(tid).await, None => None, }; let config_resp = client.config_read(None).await.ok(); lines.push("## Configuration".to_string()); if let Some(ctx) = rollout.as_ref().and_then(|r| r.turn_context.as_ref()) { if let Some(model) = &ctx.model { lines.push(format!("- **Model**: `{model}`")); } if let Some(policy) = &ctx.approval_policy { lines.push(format!("- **Approvals**: `{policy}`")); } if let Some(sandbox) = &ctx.sandbox_policy { let label = sandbox .get("type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); lines.push(format!("- **Sandbox**: `{label}`")); } let effort = ctx.effort.as_deref().unwrap_or("default"); let summary = ctx.summary.as_deref().unwrap_or("auto"); lines.push(format!( "- **Reasoning**: effort: `{effort}` summary: `{summary}`" )); } else if let Some(ref resp) = config_resp { let cfg = &resp.config; if let Some(model) = &cfg.model { lines.push(format!("- **Model**: `{model}`")); } if let Some(provider) = &cfg.model_provider { lines.push(format!("- **Provider**: `{provider}`")); } if let Some(policy) = &cfg.approval_policy { lines.push(format!("- **Approvals**: `{policy:?}`")); } if let Some(sandbox) = &cfg.sandbox_mode { lines.push(format!("- **Sandbox**: `{sandbox:?}`")); } if let Some(effort) = &cfg.model_reasoning_effort { lines.push(format!("- **Reasoning effort**: `{effort}`")); } if let Some(summary) = &cfg.model_reasoning_summary { lines.push(format!("- **Reasoning summary**: `{summary:?}`")); } } else { lines.push("_Config unavailable_".to_string()); } // Show fast mode let global_fast = config_resp .as_ref() .and_then(|r| r.config.service_tier.as_ref()) .map(|t| matches!(t, ServiceTier::Fast)) .unwrap_or(false); if global_fast || session_fast { lines.push("- **Service Tier**: `fast ⚡`".to_string()); } // Thread info if let Some(thread_id) = thread_id { lines.push(String::new()); lines.push("## Thread".to_string()); match client.thread_read(thread_id.to_string()).await { Ok(resp) => { let thread = &resp.thread; lines.push(format!("- **ID**: `{}`", thread.id)); if let Some(name) = &thread.name { lines.push(format!("- **Name**: {name}")); } lines.push(format!("- **CWD**: `{}`", thread.cwd.display())); lines.push(format!("- **CLI version**: `{}`", thread.cli_version)); let source_label = format!("{:?}", thread.source).replace("VsCode", "Vibe Kanban"); lines.push(format!("- **Source**: `{source_label}`")); } Err(err) => { lines.push(format!("_Thread info unavailable: {err}_")); } } } // Token usage (best-effort from rollout file) if let Some(rollout) = &rollout { lines.push(String::new()); lines.push("## Token Usage".to_string()); if let Some(info) = &rollout.token_usage { let total = &info.total_token_usage; let last = &info.last_token_usage; lines.push(format!("**Total**: `{}`", total.total_tokens)); lines.push(format!( " - Input: `{}` | Output: `{}` | Reasoning: `{}` | Cached: `{}`", total.input_tokens, total.output_tokens, total.reasoning_output_tokens, total.cached_input_tokens, )); lines.push(format!("\n**Last Turn**: `{}`", last.total_tokens)); lines.push(format!( " - Input: `{}` | Output: `{}` | Reasoning: `{}` | Cached: `{}`", last.input_tokens, last.output_tokens, last.reasoning_output_tokens, last.cached_input_tokens, )); if let Some(window) = info.model_context_window { lines.push(format!("\n**Context Window**: `{window}`")); } } else { lines.push("_Token usage unavailable_".to_string()); } } match client.get_account_rate_limits().await { Ok(resp) => { let rl = &resp.rate_limits; lines.push(String::new()); lines.push("## Rate Limits".to_string()); if let Some(plan) = &rl.plan_type { lines.push(format!("- **Plan**: `{plan:?}`")); } if let Some(primary) = &rl.primary { lines.push(format!("- **Primary**: `{}%` used", primary.used_percent)); } if let Some(secondary) = &rl.secondary { lines.push(format!( "- **Secondary**: `{}%` used", secondary.used_percent )); } if let Some(credits) = &rl.credits { let balance = credits.balance.as_deref().unwrap_or(if credits.unlimited { "unlimited" } else { "none" }); lines.push(format!("- **Credits**: `{balance}`")); } } Err(err) => { tracing::debug!("rate limits unavailable: {err}"); } } Ok(lines.join("\n")) } #[derive(serde::Deserialize)] struct RolloutEntry { #[serde(rename = "type")] entry_type: String, #[serde(default)] payload: serde_json::Value, } #[derive(serde::Deserialize)] struct TokenCountPayload { info: Option, } #[derive(serde::Deserialize)] struct RolloutTokenUsageInfo { total_token_usage: RolloutTokenUsage, last_token_usage: RolloutTokenUsage, model_context_window: Option, } #[derive(serde::Deserialize)] struct RolloutTokenUsage { input_tokens: u64, cached_input_tokens: u64, output_tokens: u64, reasoning_output_tokens: u64, total_tokens: u64, } #[derive(serde::Deserialize)] struct TurnContextPayload { model: Option, approval_policy: Option, sandbox_policy: Option, effort: Option, summary: Option, } struct RolloutData { turn_context: Option, token_usage: Option, } async fn read_rollout_data(session_id: &str) -> Option { let sessions_dir = codex_home()?.join("sessions"); let rollout_path = find_rollout_file(&sessions_dir, session_id).await?; let file = tokio::fs::File::open(&rollout_path).await.ok()?; let reader = tokio::io::BufReader::new(file); let mut last_turn_context: Option = None; let mut last_token_usage: Option = None; use tokio::io::AsyncBufReadExt; let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { let entry: RolloutEntry = match serde_json::from_str(&line) { Ok(e) => e, Err(_) => continue, }; match entry.entry_type.as_str() { "turn_context" => { if let Ok(ctx) = serde_json::from_value::(entry.payload) { last_turn_context = Some(ctx); } } "event_msg" => { if let Ok(tc) = serde_json::from_value::(entry.payload) && tc.info.is_some() { last_token_usage = tc.info; } } _ => {} } } Some(RolloutData { turn_context: last_turn_context, token_usage: last_token_usage, }) } async fn find_rollout_file(dir: &Path, session_id: &str) -> Option { let mut entries = tokio::fs::read_dir(dir).await.ok()?; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.is_dir() { if let Some(found) = Box::pin(find_rollout_file(&path, session_id)).await { return Some(found); } } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) && name.starts_with("rollout-") && name.contains(session_id) && name.ends_with(".jsonl") { return Some(path); } } None } async fn fetch_mcp_status_message(client: &AppServerClient) -> Result { let mut cursor = None; let mut servers = Vec::new(); loop { let response = client.list_mcp_server_status(cursor).await?; servers.extend(response.data); cursor = response.next_cursor; if cursor.is_none() { break; } } Ok(format_mcp_status(&servers)) } fn format_mcp_status(servers: &[codex_app_server_protocol::McpServerStatus]) -> String { if servers.is_empty() { return "_No MCP servers configured._".to_string(); } let mut lines = vec![format!("# MCP Servers ({})\n", servers.len())]; for server in servers { let auth = format_mcp_auth_status(&server.auth_status); lines.push(format!("## {}", server.name)); lines.push(format!("- **Auth**: `{auth}`")); let mut tools: Vec = server.tools.keys().cloned().collect(); tools.sort(); if tools.is_empty() { lines.push("- **Tools**: _none_".to_string()); } else { lines.push(format!("- **Tools**: `{}`", tools.join("`, `"))); } if !server.resources.is_empty() { let mut names: Vec = server .resources .iter() .map(|res| res.name.clone()) .collect(); names.sort(); lines.push(format!("- **Resources**: `{}`", names.join("`, `"))); } if !server.resource_templates.is_empty() { let mut names: Vec = server .resource_templates .iter() .map(|template| template.name.clone()) .collect(); names.sort(); lines.push(format!( "- **Resource Templates**: `{}`", names.join("`, `") )); } lines.push(String::new()); // Empty line between servers } lines.join("\n") } fn format_mcp_auth_status(status: &codex_app_server_protocol::McpAuthStatus) -> &'static str { match status { codex_app_server_protocol::McpAuthStatus::Unsupported => "unsupported", codex_app_server_protocol::McpAuthStatus::NotLoggedIn => "not logged in", codex_app_server_protocol::McpAuthStatus::BearerToken => "bearer token", codex_app_server_protocol::McpAuthStatus::OAuth => "oauth", } } ================================================ FILE: crates/executors/src/executors/codex.rs ================================================ pub mod client; pub mod jsonrpc; pub mod normalize_logs; pub mod review; pub mod slash_commands; use std::{ collections::HashMap, env, path::{Path, PathBuf}, str::FromStr, sync::Arc, }; /// Returns the Codex home directory. /// /// Checks the `CODEX_HOME` environment variable first, then falls back to `~/.codex`. /// This allows users to configure a custom location for Codex configuration and state. pub fn codex_home() -> Option { if let Ok(codex_home) = env::var("CODEX_HOME") && !codex_home.trim().is_empty() { return Some(PathBuf::from(codex_home)); } dirs::home_dir().map(|home| home.join(".codex")) } pub(crate) fn resolve_model(model: Option<&str>) -> (Option<&str>, bool) { match model.and_then(|m| m.strip_suffix("-fast")) { Some(base) => (Some(base), true), None => (model, false), } } pub(crate) fn fork_params_from(thread_id: String, params: ThreadStartParams) -> ThreadForkParams { ThreadForkParams { thread_id, model: params.model, model_provider: params.model_provider, cwd: params.cwd, approval_policy: params.approval_policy, sandbox: params.sandbox, config: params.config, base_instructions: params.base_instructions, developer_instructions: params.developer_instructions, service_tier: params.service_tier, ..Default::default() } } use async_trait::async_trait; use codex_app_server_protocol::{ AskForApproval as V2AskForApproval, ReviewTarget, SandboxMode as V2SandboxMode, ThreadForkParams, ThreadStartParams, UserInput, }; use codex_protocol::config_types::ServiceTier; use derivative::Derivative; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use strum_macros::{AsRefStr, EnumString}; use tokio::process::Command; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore}; use self::{ client::{AppServerClient, LogWriter}, jsonrpc::{ExitSignalSender, JsonRpcPeer}, normalize_logs::{Error, normalize_logs}, }; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, CommandParts, apply_overrides}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult, SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy, ReasoningOption}, profile::ExecutorConfig, stdout_dup::create_stdout_pipe_writer, }; /// Sandbox policy modes for Codex #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { Auto, ReadOnly, WorkspaceWrite, DangerFullAccess, } /// Determines when the user is consulted to approve Codex actions. /// /// - `UnlessTrusted`: Read-only commands are auto-approved. Everything else will /// ask the user to approve. /// - `OnFailure`: All commands run in a restricted sandbox initially. If a /// command fails, the user is asked to approve execution without the sandbox. /// - `OnRequest`: The model decides when to ask the user for approval. /// - `Never`: Commands never ask for approval. Commands that fail in the /// restricted sandbox are not retried. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { UnlessTrusted, OnFailure, OnRequest, Never, } /// Reasoning effort for the underlying model #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr, EnumString)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum ReasoningEffort { Low, Medium, High, Xhigh, } /// Model reasoning summary style #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum ReasoningSummary { Auto, Concise, Detailed, None, } /// Format for model reasoning summaries #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum ReasoningSummaryFormat { None, Experimental, } enum CodexSessionAction { Chat { prompt: String }, Review { target: ReviewTarget }, } #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct Codex { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ask_for_approval: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub oss: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_reasoning_effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_reasoning_summary: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_reasoning_summary_format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub include_apply_patch_tool: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub compact_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub developer_instructions: Option, #[serde(default)] pub plan: bool, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] approvals: Option>, } #[async_trait] impl StandardCodingAgentExecutor for Codex { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(reasoning_id) = &executor_config.reasoning_id && let Ok(reasoning_effort) = ReasoningEffort::from_str(reasoning_id) { self.model_reasoning_effort = Some(reasoning_effort) } if let Some(permission_policy) = &executor_config.permission_policy { match permission_policy { crate::model_selector::PermissionPolicy::Auto => { self.ask_for_approval = Some(AskForApproval::Never); self.plan = false; } crate::model_selector::PermissionPolicy::Supervised => { if matches!(self.ask_for_approval, None | Some(AskForApproval::Never)) { self.ask_for_approval = Some(AskForApproval::UnlessTrusted); } self.plan = false; } crate::model_selector::PermissionPolicy::Plan => { self.plan = true; } } } } fn use_approvals(&mut self, approvals: Arc) { self.approvals = Some(approvals); } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { self.spawn_slash_command(current_dir, prompt, None, env) .await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { self.spawn_slash_command(current_dir, prompt, Some(session_id), env) .await } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { normalize_logs(msg_store, worktree_path) } fn default_mcp_config_path(&self) -> Option { codex_home().map(|home| home.join("config.toml")) } fn get_availability_info(&self) -> AvailabilityInfo { if let Some(timestamp) = codex_home() .and_then(|home| std::fs::metadata(home.join("auth.json")).ok()) .and_then(|m| m.modified().ok()) .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) { return AvailabilityInfo::LoginDetected { last_auth_timestamp: timestamp, }; } let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); let installation_indicator_found = codex_home() .map(|home| home.join("version.json").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { use crate::model_selector::*; let permission_policy = if self.plan { PermissionPolicy::Plan } else if matches!(self.ask_for_approval, None | Some(AskForApproval::Never)) { PermissionPolicy::Auto } else { PermissionPolicy::Supervised }; ExecutorConfig { executor: BaseCodingAgent::Codex, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: self .model_reasoning_effort .as_ref() .map(|e| e.as_ref().to_string()), permission_policy: Some(permission_policy), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let xhigh_reasoning_options = ReasoningOption::from_names( [ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Xhigh, ] .map(|e| e.as_ref().to_string()), ); let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { models: vec![ ModelInfo { id: "gpt-5.4".to_string(), name: "GPT-5.4".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options.clone(), }, ModelInfo { id: "gpt-5.4-fast".to_string(), name: "GPT-5.4 Fast".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options.clone(), }, ModelInfo { id: "gpt-5.3-codex".to_string(), name: "GPT-5.3 Codex".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options.clone(), }, ModelInfo { id: "gpt-5.2-codex".to_string(), name: "GPT-5.2 Codex".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options.clone(), }, ModelInfo { id: "gpt-5.2".to_string(), name: "GPT-5.2".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options.clone(), }, ModelInfo { id: "gpt-5.1-codex-max".to_string(), name: "GPT-5.1 Codex Max".to_string(), provider_id: None, reasoning_options: xhigh_reasoning_options, }, ], permissions: vec![ PermissionPolicy::Auto, PermissionPolicy::Supervised, PermissionPolicy::Plan, ], ..Default::default() }, slash_commands: vec![ SlashCommandDescription { name: "compact".to_string(), description: Some( "summarize conversation to prevent hitting the context limit".to_string(), ), }, SlashCommandDescription { name: "init".to_string(), description: Some( "create an AGENTS.md file with instructions for Codex".to_string(), ), }, SlashCommandDescription { name: "status".to_string(), description: Some( "show current session configuration and token usage".to_string(), ), }, SlashCommandDescription { name: "mcp".to_string(), description: Some("list configured MCP tools".to_string()), }, SlashCommandDescription { name: "fast".to_string(), description: Some( "toggle fast mode for highest speed inference (2× plan usage). Use `/fast on` or `/fast off` to set explicitly".to_string(), ), }, ], ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } async fn spawn_review( &self, current_dir: &Path, prompt: &str, session_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let command_parts = self.build_command_builder()?.build_initial()?; let review_target = ReviewTarget::Custom { instructions: prompt.to_string(), }; let action = CodexSessionAction::Review { target: review_target, }; self.spawn_inner(current_dir, command_parts, action, session_id, env) .await } } impl Codex { pub fn base_command() -> &'static str { "npx -y @openai/codex@0.114.0" } fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new(Self::base_command()); builder = builder.extend_params(["app-server"]); if self.oss.unwrap_or(false) { builder = builder.extend_params(["--oss"]); } apply_overrides(builder, &self.cmd) } fn build_thread_start_params(&self, cwd: &Path) -> ThreadStartParams { let sandbox = match self.sandbox.as_ref() { None | Some(SandboxMode::Auto) => Some(V2SandboxMode::WorkspaceWrite), // match the Auto preset in codex Some(SandboxMode::ReadOnly) => Some(V2SandboxMode::ReadOnly), Some(SandboxMode::WorkspaceWrite) => Some(V2SandboxMode::WorkspaceWrite), Some(SandboxMode::DangerFullAccess) => Some(V2SandboxMode::DangerFullAccess), }; let approval_policy = match self.ask_for_approval.as_ref() { None if matches!(self.sandbox.as_ref(), None | Some(SandboxMode::Auto)) => { // match the Auto preset in codex Some(V2AskForApproval::OnRequest) } None => None, Some(AskForApproval::UnlessTrusted) => Some(V2AskForApproval::UnlessTrusted), Some(AskForApproval::OnFailure) => Some(V2AskForApproval::OnFailure), Some(AskForApproval::OnRequest) => Some(V2AskForApproval::OnRequest), Some(AskForApproval::Never) => Some(V2AskForApproval::Never), }; let mut config = self.build_config_overrides(); // V1 top-level params that moved into config overrides in v2 if let Some(profile) = &self.profile { config .get_or_insert_with(HashMap::new) .insert("profile".to_string(), Value::String(profile.clone())); } if let Some(include) = self.include_apply_patch_tool { config .get_or_insert_with(HashMap::new) .insert("include_apply_patch_tool".to_string(), Value::Bool(include)); } if let Some(compact) = &self.compact_prompt { config .get_or_insert_with(HashMap::new) .insert("compact_prompt".to_string(), Value::String(compact.clone())); } if !matches!(approval_policy, None | Some(V2AskForApproval::Never)) { let map = config.get_or_insert_with(HashMap::new); map.insert( "features.default_mode_request_user_input".to_string(), Value::Bool(true), ); map.insert( "suppress_unstable_features_warning".to_string(), Value::Bool(true), ); } let (model, is_fast) = resolve_model(self.model.as_deref()); let service_tier = if is_fast { Some(Some(ServiceTier::Fast)) } else { None }; ThreadStartParams { model: model.map(|m| m.to_string()), cwd: Some(cwd.to_string_lossy().to_string()), approval_policy, sandbox, config, base_instructions: self.base_instructions.clone(), model_provider: self.model_provider.clone(), developer_instructions: self.developer_instructions.clone(), service_tier, ..Default::default() } } fn build_config_overrides(&self) -> Option> { let mut overrides = HashMap::new(); if let Some(effort) = &self.model_reasoning_effort { overrides.insert( "model_reasoning_effort".to_string(), Value::String(effort.as_ref().to_string()), ); } if let Some(summary) = &self.model_reasoning_summary { overrides.insert( "model_reasoning_summary".to_string(), Value::String(summary.as_ref().to_string()), ); } if let Some(format) = &self.model_reasoning_summary_format && format != &ReasoningSummaryFormat::None { overrides.insert( "model_reasoning_summary_format".to_string(), Value::String(format.as_ref().to_string()), ); } if overrides.is_empty() { None } else { Some(overrides) } } async fn spawn_inner( &self, current_dir: &Path, command_parts: CommandParts, action: CodexSessionAction, resume_session: Option<&str>, env: &ExecutionEnv, ) -> Result { let params = self.build_thread_start_params(current_dir); let resume_session = resume_session.map(|s| s.to_string()); self.spawn_app_server( current_dir, command_parts, env, move |client, _| async move { match action { CodexSessionAction::Chat { prompt } => { Self::launch_codex_agent(params, resume_session, prompt, client).await } CodexSessionAction::Review { target } => { review::launch_codex_review(params, resume_session, target, client).await } } }, ) .await } async fn launch_codex_agent( thread_start_params: ThreadStartParams, resume_session: Option, combined_prompt: String, client: Arc, ) -> Result<(), ExecutorError> { let account = client.get_account().await?; if account.requires_openai_auth && account.account.is_none() { return Err(ExecutorError::AuthRequired( "Codex authentication required".to_string(), )); } let (thread_id, resolved_model) = match resume_session { None => { let response = client.thread_start(thread_start_params).await?; (response.thread.id, response.model) } Some(session_id) => { let response = client .thread_fork(fork_params_from(session_id, thread_start_params)) .await?; tracing::debug!("forked thread, new thread_id={}", response.thread.id); (response.thread.id, response.model) } }; client.set_resolved_model(resolved_model); client.register_session(&thread_id).await?; let collaboration_mode = client.initial_collaboration_mode()?; client .turn_start_with_mode( thread_id, vec![UserInput::Text { text: combined_prompt, text_elements: vec![], }], Some(collaboration_mode), ) .await?; Ok(()) } /// Common boilerplate for spawning a Codex app server process /// Handles process spawning, stdout/stderr piping, exit signal handling, client initialization, and error logging. /// Delegates the actual Codex session logic to the provided `task` closure. async fn spawn_app_server( &self, current_dir: &Path, command_parts: CommandParts, env: &ExecutionEnv, task: F, ) -> Result where F: FnOnce(Arc, ExitSignalSender) -> Fut + Send + 'static, Fut: std::future::Future> + Send + 'static, { let (program_path, args) = command_parts.into_resolved().await?; let mut process = Command::new(program_path); process .kill_on_drop(true) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .env("NODE_NO_WARNINGS", "1") .env("NO_COLOR", "1") .env("RUST_LOG", "error") .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut process); let mut child = process.group_spawn_no_window()?; let child_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("Codex app server missing stdout")) })?; let child_stdin = child.inner().stdin.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("Codex app server missing stdin")) })?; let new_stdout = create_stdout_pipe_writer(&mut child)?; let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel(); let cancel = tokio_util::sync::CancellationToken::new(); let auto_approve = matches!( (&self.sandbox, &self.ask_for_approval), (Some(SandboxMode::DangerFullAccess), None) ); let plan_mode = self.plan; let approvals = self.approvals.clone(); let repo_context = env.repo_context.clone(); let commit_reminder = env.commit_reminder; let commit_reminder_prompt = env.commit_reminder_prompt.clone(); let cancel_for_task = cancel.clone(); tokio::spawn(async move { let exit_signal_tx = ExitSignalSender::new(exit_signal_tx); let log_writer = LogWriter::new(new_stdout); // Initialize the AppServerClient let client = AppServerClient::new( log_writer.clone(), approvals, auto_approve, plan_mode, repo_context, commit_reminder, commit_reminder_prompt, cancel_for_task.clone(), ); let rpc_peer = JsonRpcPeer::spawn( child_stdin, child_stdout, client.clone(), exit_signal_tx.clone(), cancel_for_task, ); client.connect(rpc_peer); let result = async { client.initialize().await?; task(client, exit_signal_tx.clone()).await } .await; if let Err(err) = result { match &err { ExecutorError::Io(io_err) if io_err.kind() == std::io::ErrorKind::BrokenPipe => { // Broken pipe likely means the parent process exited, so we can ignore it return; } ExecutorError::AuthRequired(message) => { log_writer .log_raw(&Error::auth_required(message.clone()).raw()) .await .ok(); exit_signal_tx .send_exit_signal(ExecutorExitResult::Failure) .await; return; } _ => { tracing::error!("Codex spawn error: {}", err); log_writer .log_raw(&Error::launch_error(err.to_string()).raw()) .await .ok(); } } exit_signal_tx .send_exit_signal(ExecutorExitResult::Failure) .await; } }); Ok(SpawnedChild { child, exit_signal: Some(exit_signal_rx), cancel: Some(cancel), }) } } ================================================ FILE: crates/executors/src/executors/copilot.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use derivative::Derivative; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use workspace_utils::msg_store::MsgStore; pub use super::acp::AcpAgentHarness; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy}, profile::ExecutorConfig, }; #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct Copilot { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub allow_all_tools: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub allow_tool: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub deny_tool: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub add_dir: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub disable_mcp_server: Option>, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub approvals: Option>, } impl Copilot { fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new("npx -y @github/copilot@0.0.403"); if self.allow_all_tools.unwrap_or(false) { builder = builder.extend_params(["--allow-all-tools"]); } if let Some(model) = &self.model { builder = builder.extend_params(["--model", model]); } if let Some(tool) = &self.allow_tool { builder = builder.extend_params(["--allow-tool", tool]); } if let Some(tool) = &self.deny_tool { builder = builder.extend_params(["--deny-tool", tool]); } if let Some(dirs) = &self.add_dir { for dir in dirs { builder = builder.extend_params(["--add-dir", dir]); } } if let Some(servers) = &self.disable_mcp_server { for server in servers { builder = builder.extend_params(["--disable-mcp-server", server]); } } builder = builder.extend_params(["--acp"]); apply_overrides(builder, &self.cmd) } } #[async_trait] impl StandardCodingAgentExecutor for Copilot { fn use_approvals(&mut self, approvals: Arc) { self.approvals = Some(approvals); } fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(permission_policy) = &executor_config.permission_policy { self.allow_all_tools = Some(matches!( permission_policy, crate::model_selector::PermissionPolicy::Auto )); } } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let harness = AcpAgentHarness::new(); let combined_prompt = self.append_prompt.combine_prompt(prompt); let copilot_command = self.build_command_builder()?.build_initial()?; harness .spawn_with_command( current_dir, combined_prompt, copilot_command, env, &self.cmd, self.approvals.clone(), ) .await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let harness = AcpAgentHarness::new(); let combined_prompt = self.append_prompt.combine_prompt(prompt); let copilot_command = self.build_command_builder()?.build_follow_up(&[])?; harness .spawn_follow_up_with_command( current_dir, combined_prompt, session_id, copilot_command, env, &self.cmd, self.approvals.clone(), ) .await } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { super::acp::normalize_logs(msg_store, worktree_path) } fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".copilot").join("mcp-config.json")) } fn get_availability_info(&self) -> AvailabilityInfo { let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); let installation_indicator_found = dirs::home_dir() .map(|home| home.join(".copilot").join("config.json").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::Copilot, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: None, permission_policy: Some(crate::model_selector::PermissionPolicy::Auto), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { models: [ ("gpt-5.4", "GPT-5.4"), ("claude-opus-4.6", "Claude Opus 4.6"), ("claude-opus-4.6-fast", "Claude Opus 4.6 Fast"), ("gpt-5.3-codex", "GPT-5.3 Codex"), ("claude-sonnet-4.6", "Claude Sonnet 4.6"), ("claude-haiku-4.5", "Claude Haiku 4.5"), ("gemini-3-pro-preview", "Gemini 3 Pro Preview"), ("gpt-5.2-codex", "GPT-5.2 Codex"), ("gpt-5.2", "GPT-5.2"), ("gpt-5.1-codex-max", "GPT-5.1 Codex Max"), ("gpt-5.1-codex", "GPT-5.1 Codex"), ("gpt-5.1", "GPT-5.1"), ("gpt-5.1-codex-mini", "GPT-5.1 Codex Mini"), ("gpt-5-mini", "GPT-5 Mini"), ("gpt-4.1", "GPT-4.1"), ("claude-opus-4.5", "Claude Opus 4.5"), ("claude-sonnet-4.5", "Claude Sonnet 4.5"), ("claude-sonnet-4", "Claude Sonnet 4"), ] .into_iter() .map(|(id, name)| ModelInfo { id: id.to_string(), name: name.to_string(), provider_id: None, reasoning_options: vec![], }) .collect(), permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised], ..Default::default() }, ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } } ================================================ FILE: crates/executors/src/executors/cursor/mcp.rs ================================================ use std::{collections::HashSet, env, io::ErrorKind, path::Path}; use sha2::{Digest, Sha256}; use tokio::fs; use tracing::warn; use super::CursorAgent; use crate::executors::{CodingAgent, ExecutorError, StandardCodingAgentExecutor}; pub async fn ensure_mcp_server_trust(cursor: &CursorAgent, current_dir: &Path) { if let Err(err) = ensure_mcp_server_trust_impl(cursor, current_dir).await { tracing::warn!( error = %err, "Cursor MCP approval bootstrap failed. MCP servers might be unavailable." ); } } async fn ensure_mcp_server_trust_impl( cursor: &CursorAgent, current_dir: &Path, ) -> Result<(), ExecutorError> { let current_dir = std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf()); let Some(config_path) = cursor.default_mcp_config_path() else { return Ok(()); }; let Some(home_dir) = dirs::home_dir() else { return Ok(()); }; let absolute_path = if current_dir.is_absolute() { current_dir.to_path_buf() } else { match env::current_dir() { Ok(cwd) => cwd.join(current_dir), Err(_) => current_dir.to_path_buf(), } }; let worktree_path_str = absolute_path.to_string_lossy().to_string(); if worktree_path_str.is_empty() { return Ok(()); } let Some(project_slug) = cursor_project_slug(&absolute_path) else { return Ok(()); }; let config_value: serde_json::Value = match fs::read_to_string(&config_path).await { Ok(content) => match serde_json::from_str(&content) { Ok(val) => val, Err(err) => { warn!( error = ?err, path = %config_path.display(), "Failed to parse Cursor MCP config; falling back to defaults for auto-approval bootstrap" ); default_cursor_mcp_servers(cursor) } }, Err(err) if err.kind() == ErrorKind::NotFound => default_cursor_mcp_servers(cursor), Err(err) => return Err(ExecutorError::Io(err)), }; let Some(servers) = config_value .get("mcpServers") .and_then(|value| value.as_object()) else { return Ok(()); }; let approvals_path = home_dir .join(".cursor") .join("projects") .join(&project_slug) .join("mcp-approvals.json"); let mut existing: Vec = match fs::read_to_string(&approvals_path).await { Ok(content) => match serde_json::from_str(&content) { Ok(list) => list, Err(err) => { warn!( error = ?err, path = %approvals_path.display(), "Failed to parse existing Cursor MCP approvals; resetting file" ); Vec::new() } }, Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(), Err(err) => return Err(ExecutorError::Io(err)), }; let mut approvals_set: HashSet = existing.iter().cloned().collect(); let mut newly_added = Vec::new(); for (server_name, definition) in servers { if server_name == "meta" || !definition.is_object() { continue; } if let Some(approval_id) = compute_cursor_approval_id(server_name, definition, &worktree_path_str) && approvals_set.insert(approval_id.clone()) { newly_added.push(approval_id); } } if newly_added.is_empty() { return Ok(()); } existing.extend(newly_added); if let Some(parent) = approvals_path.parent() { fs::create_dir_all(parent) .await .map_err(ExecutorError::Io)?; } let serialized = serde_json::to_string_pretty(&existing)?; fs::write(&approvals_path, serialized) .await .map_err(ExecutorError::Io)?; Ok(()) } fn cursor_project_slug(path: &Path) -> Option { let raw = path.to_string_lossy(); if raw.is_empty() { return None; } let slug = regex::Regex::new(r"[^A-Za-z0-9]+") .unwrap() .replace_all(&raw, "-") .trim_matches('-') .to_string(); if slug.is_empty() { None } else { Some(slug) } } fn compute_cursor_approval_id( server_name: &str, definition: &serde_json::Value, worktree_path: &str, ) -> Option { let payload = serde_json::json!({ "path": worktree_path, "server": definition, }); let serialized = serde_json::to_string(&payload).ok()?; let mut hasher = Sha256::new(); hasher.update(serialized.as_bytes()); let digest = hasher.finalize(); let hex = digest .iter() .map(|byte| format!("{byte:02x}")) .collect::(); Some(format!("{server_name}-{}", &hex[..16])) } fn default_cursor_mcp_servers(cursor: &CursorAgent) -> serde_json::Value { let mcpc = CodingAgent::CursorAgent(cursor.clone()).get_mcp_config(); serde_json::json!({ "mcpServers": mcpc.preconfigured }) } ================================================ FILE: crates/executors/src/executors/cursor.rs ================================================ use core::str; use std::{collections::HashMap, path::Path, process::Stdio, sync::Arc, time::Duration}; use async_trait::async_trait; use futures::StreamExt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; use workspace_utils::{ command_ext::GroupSpawnNoWindowExt, diff::{create_unified_diff, normalize_unified_diff}, msg_store::MsgStore, path::make_path_relative, shell::resolve_executable_path_blocking, }; use crate::{ command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, }, logs::{ ActionType, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolStatus, plain_text_processor::PlainTextLogProcessor, utils::{ ConversationPatch, EntryIndexProvider, patch, shell_command_parsing::CommandCategory, }, }, model_selector::{ModelInfo, ModelSelectorConfig, ReasoningOption}, profile::ExecutorConfig, }; mod mcp; const CURSOR_AUTH_REQUIRED_MSG: &str = "Authentication required. Please run 'cursor-agent login' first, or set CURSOR_API_KEY environment variable."; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct CursorAgent { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(description = "Force allow commands unless explicitly denied")] pub force: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars( description = "auto, opus-4.6, sonnet-4.6, gpt-5.4, gpt-5.4-fast, gpt-5.3-codex, gpt-5.3-codex-fast, gpt-5.3-codex-spark-preview, gpt-5.2, gpt-5.2-codex, gpt-5.2-codex-fast, gpt-5.1, gpt-5.1-codex-max, gpt-5.1-codex-mini, grok, kimi-k2.5, gemini-3.1-pro, gemini-3-pro, gemini-3-flash, opus-4.5, sonnet-4.5, composer-1.5, composer-1" )] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(flatten)] pub cmd: CmdOverrides, } // get the model full name fn resolve_cursor_model_name<'a>(base_model: &'a str, reasoning: Option<&'a str>) -> &'a str { match (base_model, reasoning) { ("gpt-5.4", Some("medium")) => "gpt-5.4-medium", ("gpt-5.4", Some("high") | None) => "gpt-5.4-high", ("gpt-5.4", Some("xhigh")) => "gpt-5.4-xhigh", ("gpt-5.4-fast", Some("medium")) => "gpt-5.4-medium-fast", ("gpt-5.4-fast", Some("high") | None) => "gpt-5.4-high-fast", ("gpt-5.4-fast", Some("xhigh")) => "gpt-5.4-xhigh-fast", ("gpt-5.3-codex", Some("low")) => "gpt-5.3-codex-low", ("gpt-5.3-codex", Some("medium")) => "gpt-5.3-codex", ("gpt-5.3-codex", Some("high") | None) => "gpt-5.3-codex-high", ("gpt-5.3-codex", Some("xhigh")) => "gpt-5.3-codex-xhigh", ("gpt-5.3-codex-fast", Some("low")) => "gpt-5.3-codex-low-fast", ("gpt-5.3-codex-fast", Some("medium")) => "gpt-5.3-codex-fast", ("gpt-5.3-codex-fast", Some("high") | None) => "gpt-5.3-codex-high-fast", ("gpt-5.3-codex-fast", Some("xhigh")) => "gpt-5.3-codex-xhigh-fast", ("gpt-5.2-codex", Some("low")) => "gpt-5.2-codex-low", ("gpt-5.2-codex", Some("medium")) => "gpt-5.2-codex", ("gpt-5.2-codex", Some("high") | None) => "gpt-5.2-codex-high", ("gpt-5.2-codex", Some("xhigh")) => "gpt-5.2-codex-xhigh", ("gpt-5.2-codex-fast", Some("low")) => "gpt-5.2-codex-low-fast", ("gpt-5.2-codex-fast", Some("medium")) => "gpt-5.2-codex-fast", ("gpt-5.2-codex-fast", Some("high") | None) => "gpt-5.2-codex-high-fast", ("gpt-5.2-codex-fast", Some("xhigh")) => "gpt-5.2-codex-xhigh-fast", ("gpt-5.2", Some("medium")) => "gpt-5.2", ("gpt-5.2", Some("high") | None) => "gpt-5.2-high", ("gpt-5.1-codex-max", Some("medium")) => "gpt-5.1-codex-max", ("gpt-5.1-codex-max", Some("high") | None) => "gpt-5.1-codex-max-high", ("gpt-5.1", Some("medium")) => "gpt-5.1", ("gpt-5.1", Some("high") | None) => "gpt-5.1-high", ("opus-4.6", Some("standard")) => "opus-4.6", ("opus-4.6", Some("thinking") | None) => "opus-4.6-thinking", ("sonnet-4.6", Some("standard")) => "sonnet-4.6", ("sonnet-4.6", Some("thinking") | None) => "sonnet-4.6-thinking", ("opus-4.5", Some("standard")) => "opus-4.5", ("opus-4.5", Some("thinking") | None) => "opus-4.5-thinking", ("sonnet-4.5", Some("standard")) => "sonnet-4.5", ("sonnet-4.5", Some("thinking") | None) => "sonnet-4.5-thinking", _ => base_model, } } fn cursor_reasoning_options(base_model: &str) -> Vec { match base_model { "gpt-5.4" | "gpt-5.4-fast" => { ReasoningOption::from_names(["medium", "high", "xhigh"].map(String::from)) } "gpt-5.3-codex" | "gpt-5.3-codex-fast" | "gpt-5.2-codex" | "gpt-5.2-codex-fast" => { ReasoningOption::from_names(["low", "medium", "high", "xhigh"].map(String::from)) } "gpt-5.2" | "gpt-5.1-codex-max" | "gpt-5.1" => { ReasoningOption::from_names(["medium", "high"].map(String::from)) } "opus-4.6" | "sonnet-4.6" | "opus-4.5" | "sonnet-4.5" => vec![ ReasoningOption { id: "standard".to_string(), label: "Standard".to_string(), is_default: false, }, ReasoningOption { id: "thinking".to_string(), label: "Thinking".to_string(), is_default: true, }, ], _ => vec![], } } impl CursorAgent { pub fn base_command() -> &'static str { "cursor-agent" } fn resolved_model(&self) -> Option<&str> { self.model .as_deref() .map(|base| resolve_cursor_model_name(base, self.reasoning.as_deref())) } fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new(Self::base_command()).params(["-p", "--output-format=stream-json"]); if self.force.unwrap_or(false) { builder = builder.extend_params(["--force"]); } else { // trusting the current directory is a minimum requirement for cursor to run builder = builder.extend_params(["--trust"]); } if let Some(model) = self.resolved_model() { builder = builder.extend_params(["--model", model]); } apply_overrides(builder, &self.cmd) } } #[async_trait] impl StandardCodingAgentExecutor for CursorAgent { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(reasoning_id) = &executor_config.reasoning_id { self.reasoning = Some(reasoning_id.clone()); } if let Some(permission_policy) = executor_config.permission_policy.clone() { self.force = Some(matches!( permission_policy, crate::model_selector::PermissionPolicy::Auto )); } } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { mcp::ensure_mcp_server_trust(self, current_dir).await; let command_parts = self.build_command_builder()?.build_initial()?; let (executable_path, args) = command_parts.into_resolved().await?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(executable_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; if let Some(mut stdin) = child.inner().stdin.take() { stdin.write_all(combined_prompt.as_bytes()).await?; stdin.shutdown().await?; } Ok(child.into()) } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { mcp::ensure_mcp_server_trust(self, current_dir).await; let command_parts = self .build_command_builder()? .build_follow_up(&["--resume".to_string(), session_id.to_string()])?; let (executable_path, args) = command_parts.into_resolved().await?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut command = Command::new(executable_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; if let Some(mut stdin) = child.inner().stdin.take() { stdin.write_all(combined_prompt.as_bytes()).await?; stdin.shutdown().await?; } Ok(child.into()) } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { let entry_index_provider = EntryIndexProvider::start_from(&msg_store); // Custom stderr processor for Cursor that detects login errors let msg_store_stderr = msg_store.clone(); let entry_index_provider_stderr = entry_index_provider.clone(); let h1 = tokio::spawn(async move { let mut stderr = msg_store_stderr.stderr_chunked_stream(); let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(Box::new(|content: String| { let content = strip_ansi_escapes::strip_str(&content); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content, metadata: None, } })) .time_gap(Duration::from_secs(2)) .index_provider(entry_index_provider_stderr.clone()) .build(); while let Some(Ok(chunk)) = stderr.next().await { let content = strip_ansi_escapes::strip_str(&chunk); if content.contains(CURSOR_AUTH_REQUIRED_MSG) { let error_message = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::SetupRequired, }, content: content.to_string(), metadata: None, }; let id = entry_index_provider_stderr.next(); msg_store_stderr .push_patch(ConversationPatch::add_normalized_entry(id, error_message)); } else { // Always emit error message for patch in processor.process(chunk) { msg_store_stderr.push_patch(patch); } } } }); // Process Cursor stdout JSONL with typed serde models let current_dir = worktree_path.to_path_buf(); let h2 = tokio::spawn(async move { let mut lines = msg_store.stdout_lines_stream(); // Assistant streaming coalescer state let mut model_reported = false; let mut session_id_reported = false; let mut current_assistant_message_buffer = String::new(); let mut current_assistant_message_index: Option = None; let mut current_thinking_message_buffer = String::new(); let mut current_thinking_message_index: Option = None; let worktree_str = current_dir.to_string_lossy().to_string(); use std::collections::HashMap; // Track tool call_id -> entry index let mut call_index_map: HashMap = HashMap::new(); while let Some(Ok(line)) = lines.next().await { // Parse line as CursorJson let cursor_json: CursorJson = match serde_json::from_str(&line) { Ok(cursor_json) => cursor_json, Err(_) => { // Handle non-JSON output as raw system message if !line.is_empty() { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: line.to_string(), metadata: None, }; let patch_id = entry_index_provider.next(); let patch = ConversationPatch::add_normalized_entry(patch_id, entry); msg_store.push_patch(patch); } continue; } }; // Push session_id if present if !session_id_reported && let Some(session_id) = cursor_json.extract_session_id() { msg_store.push_session_id(session_id); session_id_reported = true; } let is_assistant_message = matches!(cursor_json, CursorJson::Assistant { .. }); let is_thinking_message = matches!(cursor_json, CursorJson::Thinking { .. }); if !is_assistant_message && current_assistant_message_index.is_some() { // flush current_assistant_message_index = None; current_assistant_message_buffer.clear(); } if !is_thinking_message && current_thinking_message_index.is_some() { current_thinking_message_index = None; current_thinking_message_buffer.clear(); } match &cursor_json { CursorJson::System { model, .. } => { if !model_reported && let Some(model) = model.as_ref() { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("System initialized with model: {model}"), metadata: None, }; let id = entry_index_provider.next(); msg_store .push_patch(ConversationPatch::add_normalized_entry(id, entry)); model_reported = true; } } CursorJson::User { .. } => {} CursorJson::Assistant { message, .. } => { if let Some(chunk) = message.concat_text() { current_assistant_message_buffer.push_str(&chunk); let replace_entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::AssistantMessage, content: current_assistant_message_buffer.clone(), metadata: None, }; if let Some(id) = current_assistant_message_index { msg_store.push_patch(ConversationPatch::replace(id, replace_entry)) } else { let id = entry_index_provider.next(); current_assistant_message_index = Some(id); msg_store.push_patch(ConversationPatch::add_normalized_entry( id, replace_entry, )); }; } } CursorJson::Thinking { text, .. } => { if let Some(chunk) = text && !chunk.is_empty() { current_thinking_message_buffer.push_str(chunk); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::Thinking, content: current_thinking_message_buffer.clone(), metadata: None, }; if let Some(id) = current_thinking_message_index { msg_store.push_patch(ConversationPatch::replace(id, entry)); } else { let id = entry_index_provider.next(); current_thinking_message_index = Some(id); msg_store .push_patch(ConversationPatch::add_normalized_entry(id, entry)); } } } CursorJson::ToolCall { subtype, call_id, tool_call, .. } => { // Only process "started" subtype (completed contains results we currently ignore) if subtype .as_deref() .map(|s| s.eq_ignore_ascii_case("started")) .unwrap_or(false) { let tool_name = tool_call.get_name().to_string(); let (action_type, content) = tool_call.to_action_and_content(&worktree_str); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name, action_type, status: ToolStatus::Created, }, content, metadata: None, }; let id = entry_index_provider.next(); if let Some(cid) = call_id.as_ref() { call_index_map.insert(cid.clone(), id); } msg_store .push_patch(ConversationPatch::add_normalized_entry(id, entry)); } else if subtype .as_deref() .map(|s| s.eq_ignore_ascii_case("completed")) .unwrap_or(false) && let Some(cid) = call_id.as_ref() && let Some(&idx) = call_index_map.get(cid) { // Compute base content and action again let (mut new_action, content_str) = tool_call.to_action_and_content(&worktree_str); if let CursorToolCall::Shell { args, result } = &tool_call { // Merge stdout/stderr and derive exit status when available using typed deserialization let (stdout_val, stderr_val, exit_code) = if let Some(res) = result { match serde_json::from_value::(res.clone()) { Ok(r) => { if let Some(out) = r.into_outcome() { (out.stdout, out.stderr, out.exit_code) } else { (None, None, None) } } Err(_) => (None, None, None), } } else { (None, None, None) }; let output = match (stdout_val, stderr_val) { (Some(sout), Some(serr)) => { let st = sout.trim(); let se = serr.trim(); if st.is_empty() && se.is_empty() { None } else if st.is_empty() { Some(serr) } else if se.is_empty() { Some(sout) } else { Some(format!("STDOUT:\n{st}\n\nSTDERR:\n{se}")) } } (Some(sout), None) => { if sout.trim().is_empty() { None } else { Some(sout) } } (None, Some(serr)) => { if serr.trim().is_empty() { None } else { Some(serr) } } (None, None) => None, }; let exit_status = exit_code .map(|code| crate::logs::CommandExitStatus::ExitCode { code }); new_action = ActionType::CommandRun { command: args.command.clone(), result: Some(crate::logs::CommandRunResult { exit_status, output, }), category: CommandCategory::from_command(&args.command), }; } else if let CursorToolCall::Mcp { args, result } = &tool_call { // Extract a human-readable text from content array using typed deserialization let md: Option = if let Some(res) = result { match serde_json::from_value::(res.clone()) { Ok(r) => r.into_markdown(), Err(_) => None, } } else { None }; let provider = args.provider_identifier.as_deref().unwrap_or("mcp"); let tname = args.tool_name.as_deref().unwrap_or(&args.name); let label = format!("mcp:{provider}:{tname}"); new_action = ActionType::Tool { tool_name: label.clone(), arguments: Some(serde_json::json!({ "name": args.name, "args": args.args, "providerIdentifier": args.provider_identifier, "toolName": args.tool_name, })), result: md.map(|s| crate::logs::ToolResult { r#type: crate::logs::ToolResultValueType::Markdown, value: serde_json::Value::String(s), }), }; } let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: match &tool_call { CursorToolCall::Mcp { args, .. } => { let provider = args .provider_identifier .as_deref() .unwrap_or("mcp"); let tname = args.tool_name.as_deref().unwrap_or(&args.name); format!("mcp:{provider}:{tname}") } _ => tool_call.get_name().to_string(), }, action_type: new_action, status: ToolStatus::Success, }, content: content_str, metadata: None, }; msg_store.push_patch(ConversationPatch::replace(idx, entry)); } } CursorJson::Result { .. } => { // no-op; metadata-only events not surfaced } CursorJson::Unknown => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: line, metadata: None, }; let id = entry_index_provider.next(); msg_store.push_patch(ConversationPatch::add_normalized_entry(id, entry)); } } } }); vec![h1, h2] } fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")) } fn get_availability_info(&self) -> AvailabilityInfo { let binary_found = resolve_executable_path_blocking(Self::base_command()).is_some(); if !binary_found { return AvailabilityInfo::NotFound; } let config_files_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); if config_files_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::CursorAgent, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: self.reasoning.clone(), permission_policy: Some(crate::model_selector::PermissionPolicy::Auto), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let models: Vec = [ ("auto", "Auto"), ("composer-1.5", "Composer 1.5"), ("gpt-5.4", "GPT-5.4"), ("gpt-5.4-fast", "GPT-5.4 Fast"), ("gemini-3.1-pro", "Gemini 3.1 Pro"), ("opus-4.6", "Claude 4.6 Opus"), ("sonnet-4.6", "Claude 4.6 Sonnet"), ("gpt-5.3-codex", "GPT-5.3 Codex"), ("gpt-5.3-codex-fast", "GPT-5.3 Codex Fast"), ("gpt-5.3-codex-spark-preview", "GPT-5.3 Codex Spark"), ("kimi-k2.5", "Kimi K2.5"), ("opus-4.5", "Claude 4.5 Opus"), ("sonnet-4.5", "Claude 4.5 Sonnet"), ("gemini-3-pro", "Gemini 3 Pro"), ("gemini-3-flash", "Gemini 3 Flash"), ("gpt-5.2-codex", "GPT-5.2 Codex"), ("gpt-5.2-codex-fast", "GPT-5.2 Codex Fast"), ("gpt-5.2", "GPT-5.2"), ("gpt-5.1-codex-max", "GPT-5.1 Codex Max"), ("gpt-5.1", "GPT-5.1"), ("gpt-5.1-codex-mini", "GPT-5.1 Codex Mini"), ("grok", "Grok"), ("composer-1", "Composer 1"), ] .into_iter() .map(|(id, name)| ModelInfo { id: id.to_string(), name: name.to_string(), provider_id: None, reasoning_options: cursor_reasoning_options(id), }) .collect(); let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { models, ..Default::default() }, ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } } /* =========================== Typed Cursor JSON structures =========================== */ #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub enum CursorJson { #[serde(rename = "system")] System { #[serde(default)] subtype: Option, #[serde(default, rename = "apiKeySource")] api_key_source: Option, #[serde(default)] cwd: Option, #[serde(default)] session_id: Option, #[serde(default)] model: Option, #[serde(default, rename = "permissionMode")] permission_mode: Option, }, #[serde(rename = "user")] User { message: CursorMessage, #[serde(default)] session_id: Option, }, #[serde(rename = "assistant")] Assistant { message: CursorMessage, #[serde(default)] session_id: Option, }, #[serde(rename = "thinking")] Thinking { #[serde(default)] subtype: Option, #[serde(default)] text: Option, #[serde(default)] session_id: Option, }, #[serde(rename = "tool_call")] ToolCall { #[serde(default)] subtype: Option, // "started" | "completed" #[serde(default)] call_id: Option, tool_call: CursorToolCall, #[serde(default)] session_id: Option, }, #[serde(rename = "result")] Result { #[serde(default)] subtype: Option, #[serde(default)] is_error: Option, #[serde(default)] duration_ms: Option, #[serde(default)] result: Option, #[serde(default)] session_id: Option, }, #[serde(other)] Unknown, } impl CursorJson { pub fn extract_session_id(&self) -> Option { match self { CursorJson::System { .. } => None, // session might not have been initialized yet CursorJson::User { session_id, .. } => session_id.clone(), CursorJson::Assistant { session_id, .. } => session_id.clone(), CursorJson::Thinking { session_id, .. } => session_id.clone(), CursorJson::ToolCall { session_id, .. } => session_id.clone(), CursorJson::Result { session_id, .. } => session_id.clone(), CursorJson::Unknown => None, } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMessage { pub role: String, pub content: Vec, } impl CursorMessage { pub fn concat_text(&self) -> Option { let mut out = String::new(); for CursorContentItem::Text { text } in &self.content { out.push_str(text); } if out.is_empty() { None } else { Some(out) } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "type")] pub enum CursorContentItem { #[serde(rename = "text")] Text { text: String }, } /* =========================== Tool call structure =========================== */ #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub enum CursorToolCall { #[serde(rename = "shellToolCall")] Shell { args: CursorShellArgs, #[serde(default)] result: Option, }, #[serde(rename = "lsToolCall")] LS { args: CursorLsArgs, #[serde(default)] result: Option, }, #[serde(rename = "globToolCall")] Glob { args: CursorGlobArgs, #[serde(default)] result: Option, }, #[serde(rename = "grepToolCall")] Grep { args: CursorGrepArgs, #[serde(default)] result: Option, }, #[serde(rename = "semSearchToolCall")] SemSearch { args: CursorSemSearchArgs, #[serde(default)] result: Option, }, #[serde(rename = "writeToolCall")] Write { args: CursorWriteArgs, #[serde(default)] result: Option, }, #[serde(rename = "readToolCall")] Read { args: CursorReadArgs, #[serde(default)] result: Option, }, #[serde(rename = "editToolCall")] Edit { args: CursorEditArgs, #[serde(default)] result: Option, }, #[serde(rename = "deleteToolCall")] Delete { args: CursorDeleteArgs, #[serde(default)] result: Option, }, #[serde(rename = "updateTodosToolCall")] Todo { args: CursorUpdateTodosArgs, #[serde(default)] result: Option, }, #[serde(rename = "mcpToolCall")] Mcp { args: CursorMcpArgs, #[serde(default)] result: Option, }, /// Generic fallback for unknown tools (amp.rs pattern) #[serde(untagged)] Unknown { #[serde(flatten)] data: std::collections::HashMap, }, } impl CursorToolCall { pub fn get_name(&self) -> &str { match self { CursorToolCall::Shell { .. } => "shell", CursorToolCall::LS { .. } => "ls", CursorToolCall::Glob { .. } => "glob", CursorToolCall::Grep { .. } => "grep", CursorToolCall::SemSearch { .. } => "semsearch", CursorToolCall::Write { .. } => "write", CursorToolCall::Read { .. } => "read", CursorToolCall::Edit { .. } => "edit", CursorToolCall::Delete { .. } => "delete", CursorToolCall::Todo { .. } => "todo", CursorToolCall::Mcp { .. } => "mcp", CursorToolCall::Unknown { data } => { data.keys().next().map(|s| s.as_str()).unwrap_or("unknown") } } } pub fn to_action_and_content(&self, worktree_path: &str) -> (ActionType, String) { match self { CursorToolCall::Read { args, .. } => { let path = make_path_relative(&args.path, worktree_path); (ActionType::FileRead { path: path.clone() }, path) } CursorToolCall::Write { args, .. } => { let path = make_path_relative(&args.path, worktree_path); ( ActionType::FileEdit { path: path.clone(), changes: vec![], }, path, ) } CursorToolCall::Edit { args, result, .. } => { let path = make_path_relative(&args.path, worktree_path); let mut changes = vec![]; if let Some(apply_patch) = &args.apply_patch { changes.push(FileChange::Edit { unified_diff: normalize_unified_diff(&path, &apply_patch.patch_content), has_line_numbers: false, }); } if let Some(str_replace) = &args.str_replace { changes.push(FileChange::Edit { unified_diff: create_unified_diff( &path, &str_replace.old_text, &str_replace.new_text, ), has_line_numbers: false, }); } if let Some(multi_str_replace) = &args.multi_str_replace { let edits: Vec = multi_str_replace .edits .iter() .map(|edit| FileChange::Edit { unified_diff: create_unified_diff( &path, &edit.old_text, &edit.new_text, ), has_line_numbers: false, }) .collect(); changes.extend(edits); } if changes.is_empty() && let Some(CursorEditResult::Success(CursorEditSuccessResult { diff_string: Some(diff_string), .. })) = &result { changes.push(FileChange::Edit { unified_diff: normalize_unified_diff(&path, diff_string), has_line_numbers: false, }); } ( ActionType::FileEdit { path: path.clone(), changes, }, path, ) } CursorToolCall::Delete { args, .. } => { let path = make_path_relative(&args.path, worktree_path); ( ActionType::FileEdit { path: path.clone(), changes: vec![FileChange::Delete], }, path.to_string(), ) } CursorToolCall::Shell { args, .. } => { let cmd = &args.command; ( ActionType::CommandRun { command: cmd.clone(), result: None, category: CommandCategory::from_command(cmd), }, cmd.to_string(), ) } CursorToolCall::Grep { args, .. } => { let pattern = &args.pattern; ( ActionType::Search { query: pattern.clone(), }, pattern.to_string(), ) } CursorToolCall::SemSearch { args, .. } => { let query = &args.query; ( ActionType::Search { query: query.clone(), }, query.to_string(), ) } CursorToolCall::Glob { args, .. } => { let pattern = args.glob_pattern.clone().unwrap_or_else(|| "*".to_string()); if let Some(path) = args.path.as_ref().or(args.target_directory.as_ref()) { let path = make_path_relative(path, worktree_path); ( ActionType::Search { query: pattern.clone(), }, format!("Find files: `{pattern}` in {path}"), ) } else { ( ActionType::Search { query: pattern.clone(), }, format!("Find files: `{pattern}`"), ) } } CursorToolCall::LS { args, .. } => { let path = make_path_relative(&args.path, worktree_path); let content = if path.is_empty() { "List directory".to_string() } else { format!("List directory: {path}") }; ( ActionType::Other { description: "List directory".to_string(), }, content, ) } CursorToolCall::Todo { args, .. } => { let todos = args .todos .as_ref() .map(|todos| { todos .iter() .map(|t| TodoItem { content: t.content.clone(), status: normalize_todo_status(&t.status), priority: None, // CursorTodoItem doesn't have priority field }) .collect() }) .unwrap_or_default(); ( ActionType::TodoManagement { todos, operation: "write".to_string(), }, "TODO list updated".to_string(), ) } CursorToolCall::Mcp { args, .. } => { let provider = args.provider_identifier.as_deref().unwrap_or("mcp"); let tool_name = args.tool_name.as_deref().unwrap_or(&args.name); let label = format!("mcp:{provider}:{tool_name}"); let summary = tool_name.to_string(); let mut arguments = serde_json::json!({ "name": args.name, "args": args.args, }); if let Some(p) = &args.provider_identifier { arguments["providerIdentifier"] = serde_json::Value::String(p.clone()); } if let Some(tn) = &args.tool_name { arguments["toolName"] = serde_json::Value::String(tn.clone()); } ( ActionType::Tool { tool_name: label, arguments: Some(arguments), result: None, }, summary, ) } CursorToolCall::Unknown { .. } => ( ActionType::Other { description: format!("Tool: {}", self.get_name()), }, self.get_name().to_string(), ), } } } fn normalize_todo_status(status: &str) -> String { match status.to_lowercase().as_str() { "todo_status_pending" => "pending".to_string(), "todo_status_in_progress" => "in_progress".to_string(), "todo_status_completed" => "completed".to_string(), "todo_status_cancelled" => "cancelled".to_string(), other => other.to_string(), } } /* =========================== Typed tool results for Cursor =========================== */ #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorShellOutcome { #[serde(default)] pub stdout: Option, #[serde(default)] pub stderr: Option, #[serde(default, rename = "exitCode")] pub exit_code: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorShellWrappedResult { #[serde(default)] pub success: Option, #[serde(default)] pub failure: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum CursorShellResult { Wrapped(CursorShellWrappedResult), Flat(CursorShellOutcome), Unknown(serde_json::Value), } impl CursorShellResult { pub fn into_outcome(self) -> Option { match self { CursorShellResult::Flat(o) => Some(o), CursorShellResult::Wrapped(w) => w.success.or(w.failure), CursorShellResult::Unknown(_) => None, } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMcpTextInner { pub text: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMcpContentItem { #[serde(default)] pub text: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMcpOutcome { #[serde(default)] pub content: Option>, #[serde(default, rename = "isError")] pub is_error: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMcpWrappedResult { #[serde(default)] pub success: Option, #[serde(default)] pub failure: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum CursorMcpResult { Wrapped(CursorMcpWrappedResult), Flat(CursorMcpOutcome), Unknown(serde_json::Value), } impl CursorMcpResult { pub fn into_markdown(self) -> Option { let outcome = match self { CursorMcpResult::Flat(o) => Some(o), CursorMcpResult::Wrapped(w) => w.success.or(w.failure), CursorMcpResult::Unknown(_) => None, }?; let items = outcome.content.unwrap_or_default(); let mut parts: Vec = Vec::new(); for item in items { if let Some(t) = item.text { parts.push(t.text); } } if parts.is_empty() { None } else { Some(parts.join("\n\n")) } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorShellArgs { pub command: String, #[serde(default, alias = "working_directory", alias = "workingDirectory")] pub working_directory: Option, #[serde(default)] pub timeout: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorLsArgs { pub path: String, #[serde(default)] pub ignore: Vec, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorGlobArgs { #[serde(default, alias = "globPattern", alias = "glob_pattern")] pub glob_pattern: Option, #[serde(default, alias = "targetDirectory")] pub path: Option, #[serde(default, alias = "target_directory")] pub target_directory: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorGrepArgs { pub pattern: String, #[serde(default)] pub path: Option, #[serde(default, alias = "glob")] pub glob_filter: Option, #[serde(default, alias = "outputMode", alias = "output_mode")] pub output_mode: Option, #[serde(default, alias = "-i", alias = "caseInsensitive")] pub case_insensitive: Option, #[serde(default)] pub multiline: Option, #[serde(default, alias = "headLimit", alias = "head_limit")] pub head_limit: Option, #[serde(default)] pub r#type: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorSemSearchArgs { pub query: String, #[serde(default, alias = "targetDirectories")] pub target_directories: Option>, #[serde(default)] pub explanation: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorWriteArgs { pub path: String, #[serde( default, alias = "fileText", alias = "file_text", alias = "contents", alias = "content" )] pub contents: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorReadArgs { pub path: String, #[serde(default)] pub offset: Option, #[serde(default)] pub limit: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorEditArgs { pub path: String, #[serde(default, rename = "applyPatch")] pub apply_patch: Option, #[serde(default, rename = "strReplace")] pub str_replace: Option, #[serde(default, rename = "multiStrReplace")] pub multi_str_replace: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "lowercase")] pub enum CursorEditResult { Success(CursorEditSuccessResult), #[serde(untagged)] Unknown { #[serde(flatten)] data: HashMap, }, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorEditSuccessResult { pub path: String, #[serde(default, rename = "resultForModel")] pub result_for_model: Option, #[serde(default, rename = "linesAdded")] pub lines_added: Option, #[serde(default, rename = "linesRemoved")] pub lines_removed: Option, #[serde(default, rename = "diffString")] pub diff_string: Option, #[serde(default, rename = "afterFullFileContent")] pub after_full_file_content: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorApplyPatch { #[serde(rename = "patchContent")] pub patch_content: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorStrReplace { #[serde(rename = "oldText")] pub old_text: String, #[serde(rename = "newText")] pub new_text: String, #[serde(default, rename = "replaceAll")] pub replace_all: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMultiStrReplace { pub edits: Vec, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMultiEditItem { #[serde(rename = "oldText")] pub old_text: String, #[serde(rename = "newText")] pub new_text: String, #[serde(default, rename = "replaceAll")] pub replace_all: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorDeleteArgs { pub path: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorUpdateTodosArgs { #[serde(default)] pub todos: Option>, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorMcpArgs { pub name: String, #[serde(default)] pub args: serde_json::Value, #[serde(default, alias = "providerIdentifier")] pub provider_identifier: Option, #[serde(default, alias = "toolName")] pub tool_name: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct CursorTodoItem { #[serde(default)] pub id: Option, pub content: String, pub status: String, #[serde(default, rename = "createdAt")] pub created_at: Option, #[serde(default, rename = "updatedAt")] pub updated_at: Option, #[serde(default)] pub dependencies: Option>, } /* =========================== Tests =========================== */ #[cfg(test)] mod tests { use std::sync::Arc; use workspace_utils::msg_store::MsgStore; use super::*; #[tokio::test] async fn test_cursor_streaming_patch_generation() { // Avoid relying on feature flag in tests; construct with a dummy command let executor = CursorAgent { append_prompt: AppendPrompt::default(), force: None, model: None, reasoning: None, cmd: Default::default(), }; let msg_store = Arc::new(MsgStore::new()); let current_dir = std::path::PathBuf::from("/tmp/test-worktree"); // A minimal synthetic init + assistant micro-chunks (as Cursor would emit) msg_store.push_stdout(format!( "{}\n", r#"{"type":"system","subtype":"init","session_id":"sess-123","model":"OpenAI GPT-5"}"# )); msg_store.push_stdout(format!( "{}\n", r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}"# )); msg_store.push_stdout(format!( "{}\n", r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":" world"}]}}"# )); msg_store.push_finished(); executor.normalize_logs(msg_store.clone(), ¤t_dir); tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; // Verify patches were emitted (system init + assistant add/replace) let history = msg_store.get_history(); let patch_count = history .iter() .filter(|m| matches!(m, workspace_utils::log_msg::LogMsg::JsonPatch(_))) .count(); assert!( patch_count >= 2, "Expected at least 2 patches, got {patch_count}" ); } #[test] fn test_session_id_extraction_from_system_line() { // System messages no longer extract session_id let system_line = r#"{"type":"system","subtype":"init","session_id":"abc-xyz","model":"Claude 4 Sonnet"}"#; let parsed: CursorJson = serde_json::from_str(system_line).unwrap(); assert_eq!(parsed.extract_session_id().as_deref(), None); } #[test] fn test_cursor_tool_call_parsing() { // Test known variant (from reference JSONL) let shell_tool_json = r#"{"shellToolCall":{"args":{"command":"wc -l drill.md","workingDirectory":"","timeout":0}}}"#; let parsed: CursorToolCall = serde_json::from_str(shell_tool_json).unwrap(); match parsed { CursorToolCall::Shell { args, result } => { assert_eq!(args.command, "wc -l drill.md"); assert_eq!(args.working_directory, Some("".to_string())); assert_eq!(args.timeout, Some(0)); assert_eq!(result, None); } _ => panic!("Expected Shell variant"), } // Test unknown variant (captures raw data) let unknown_tool_json = r#"{"unknownTool":{"args":{"someData":"value"},"result":{"status":"success"}}}"#; let parsed: CursorToolCall = serde_json::from_str(unknown_tool_json).unwrap(); match parsed { CursorToolCall::Unknown { data } => { assert!(data.contains_key("unknownTool")); let unknown_tool = &data["unknownTool"]; assert_eq!(unknown_tool["args"]["someData"], "value"); assert_eq!(unknown_tool["result"]["status"], "success"); } _ => panic!("Expected Unknown variant"), } } } ================================================ FILE: crates/executors/src/executors/droid/normalize_logs.rs ================================================ use std::{ collections::{HashMap, VecDeque}, path::Path, sync::Arc, }; use futures::{StreamExt, future::ready}; use serde::{Deserialize, Serialize}; use serde_json::Value; use workspace_utils::{ diff::normalize_unified_diff, msg_store::MsgStore, path::make_path_relative, }; use crate::logs::{ ActionType, CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, ToolResult, ToolStatus, plain_text_processor::PlainTextLogProcessor, utils::{ EntryIndexProvider, patch::{add_normalized_entry, replace_normalized_entry}, shell_command_parsing::CommandCategory, }, }; pub fn normalize_logs( msg_store: Arc, worktree_path: &Path, entry_index_provider: EntryIndexProvider, ) -> Vec> { let h1 = normalize_stderr_logs(msg_store.clone(), entry_index_provider.clone()); let worktree_path = worktree_path.to_path_buf(); let h2 = tokio::spawn(async move { let mut state = ToolCallStates::new(entry_index_provider.clone()); let mut session_id_extracted = false; let mut sent_completion = false; let worktree_path_str = worktree_path.to_string_lossy(); let mut lines_stream = msg_store .stdout_lines_stream() .filter_map(|res| ready(res.ok())); while let Some(line) = lines_stream.next().await { let trimmed = line.trim(); let droid_json = match serde_json::from_str::(trimmed) { Ok(droid_json) => droid_json, Err(_) => { if let Ok(DroidErrorLog { error, .. }) = serde_json::from_str::(trimmed) { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: error.message, metadata: None, }; add_normalized_entry(&msg_store, &entry_index_provider, entry); continue; } // Handle non-JSON output as raw system message if !trimmed.is_empty() { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: strip_ansi_escapes::strip_str(trimmed).to_string(), metadata: None, }; add_normalized_entry(&msg_store, &entry_index_provider, entry); } continue; } }; // Extract session ID if not already done if !session_id_extracted && let Some(session_id) = droid_json.session_id() { msg_store.push_session_id(session_id.to_string()); session_id_extracted = true; } // Normalize JSON logs match droid_json { DroidJson::System { model, .. } => { if !state.model_reported && let Some(model) = model { state.model_reported = true; let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: format!("model: {model}"), metadata: None, }; add_normalized_entry(&msg_store, &entry_index_provider, entry); } } DroidJson::Message { role, text, .. } => { if role == "assistant" && sent_completion { continue; } let entry_type = match role.as_str() { "user" => NormalizedEntryType::UserMessage, "assistant" => NormalizedEntryType::AssistantMessage, _ => NormalizedEntryType::SystemMessage, }; let entry = NormalizedEntry { timestamp: None, entry_type, content: text.clone(), metadata: None, }; add_normalized_entry(&msg_store, &entry_index_provider, entry); } DroidJson::ToolCall { id, tool_name, parameters: arguments, .. } => { let tool_json = serde_json::json!({ "toolName": tool_name, "parameters": arguments }); if let Ok(tool_data) = serde_json::from_value::(tool_json) { match tool_data { DroidToolData::Read { file_path } | DroidToolData::LS { directory_path: file_path, .. } => { let path = make_path_relative(&file_path, &worktree_path_str); let tool_state = FileReadState { index: None, path: path.clone(), status: ToolStatus::Created, }; state.file_reads.insert(id.to_string(), tool_state); state.pending_fifo.push_back(PendingToolCall::Read { tool_call_id: id.to_string(), }); let tool_state = state.file_reads.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::Grep { path: file_path, .. } => { let path = file_path .as_ref() .map(|p| make_path_relative(p, &worktree_path_str)) .unwrap_or_default(); let tool_state = FileReadState { index: None, path: path.clone(), status: ToolStatus::Created, }; state.file_reads.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Read { tool_call_id: id.clone(), }); let tool_state = state.file_reads.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::Glob { patterns, .. } => { let query = patterns.join(", "); let tool_state = SearchState { index: None, query: query.clone(), status: ToolStatus::Created, }; state.searches.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Search { tool_call_id: id.clone(), }); let tool_state = state.searches.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::Execute { command, .. } => { let tool_state = CommandRunState { index: None, command: command.clone(), output: String::new(), status: ToolStatus::Created, exit_code: None, }; state.command_runs.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::CommandRun { tool_call_id: id.clone(), }); let tool_state = state.command_runs.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::Edit { file_path, old_string, new_string, } => { let path = make_path_relative(&file_path, &worktree_path_str); let diff = workspace_utils::diff::create_unified_diff( &file_path, &old_string, &new_string, ); let changes = vec![FileChange::Edit { unified_diff: diff, has_line_numbers: false, }]; let tool_state = FileEditState { index: None, path: path.clone(), changes: changes.clone(), status: ToolStatus::Created, }; state.file_edits.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::FileEdit { tool_call_id: id.clone(), }); let tool_state = state.file_edits.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::MultiEdit { file_path, edits } => { let path = make_path_relative(&file_path, &worktree_path_str); let changes: Vec = edits .iter() .filter_map(|edit| { if edit.old_string.is_some() || edit.new_string.is_some() { Some(FileChange::Edit { unified_diff: workspace_utils::diff::create_unified_diff( &file_path, &edit .old_string .clone() .unwrap_or_default(), &edit .new_string .clone() .unwrap_or_default(), ), has_line_numbers: false, }) } else { None } }) .collect(); let tool_state = FileEditState { index: None, path: path.clone(), changes: changes.clone(), status: ToolStatus::Created, }; state.file_edits.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::FileEdit { tool_call_id: id.clone(), }); let tool_state = state.file_edits.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::Create { file_path, content } => { let path = make_path_relative(&file_path, &worktree_path_str); let changes = vec![FileChange::Write { content }]; let tool_state = FileEditState { index: None, path: path.clone(), changes: changes.clone(), status: ToolStatus::Created, }; state.file_edits.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::FileEdit { tool_call_id: id.clone(), }); let tool_state = state.file_edits.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::ApplyPatch { input } => { let path = extract_path_from_patch(&input); let path = make_path_relative(&path, &worktree_path_str); // We get changes from tool result let tool_state = FileEditState { index: None, path: path.clone(), changes: vec![], status: ToolStatus::Created, }; state.file_edits.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::FileEdit { tool_call_id: id.clone(), }); let tool_state = state.file_edits.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::TodoWrite { todos } => { let todo_items: Vec = todos .into_iter() .map(|item| TodoItem { content: item.content, status: item.status, priority: item.priority, }) .collect(); let tool_state = TodoManagementState { index: None, todos: todo_items.clone(), status: ToolStatus::Created, }; state.todo_updates.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Todo { tool_call_id: id.clone(), }); let tool_state = state.todo_updates.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::WebSearch { query, .. } => { let tool_state = WebFetchState { index: None, url: query.clone(), status: ToolStatus::Created, }; state.web_fetches.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Fetch { tool_call_id: id.clone(), }); let tool_state = state.web_fetches.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::FetchUrl { url, .. } => { let tool_state = WebFetchState { index: None, url: url.clone(), status: ToolStatus::Created, }; state.web_fetches.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Fetch { tool_call_id: id.clone(), }); let tool_state = state.web_fetches.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::ExitSpecMode { .. } => { let tool_state = TodoManagementState { index: None, todos: vec![], status: ToolStatus::Created, }; state.todo_updates.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Todo { tool_call_id: id.clone(), }); let tool_state = state.todo_updates.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } DroidToolData::SlackPostMessage { .. } | DroidToolData::Unknown { .. } => { let tool_state = GenericToolState { index: None, name: tool_name.to_string(), arguments: Some(arguments.clone()), result: None, status: ToolStatus::Created, }; state.generic_tools.insert(id.clone(), tool_state); state.pending_fifo.push_back(PendingToolCall::Generic { tool_call_id: id.clone(), }); let tool_state = state.generic_tools.get_mut(&id).unwrap(); let index = add_normalized_entry( &msg_store, &entry_index_provider, tool_state.to_normalized_entry(), ); tool_state.index = Some(index); } } } else { tracing::warn!("Failed to parse tool parameters for {}", tool_name); } } DroidJson::ToolResult { id: _, is_error, payload, .. } => { if let Some(pending_tool_call) = state.pending_fifo.pop_front() { match pending_tool_call { PendingToolCall::Read { tool_call_id } => { if let Some(mut state) = state.file_reads.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::FileEdit { tool_call_id } => { if let Some(mut state) = state.file_edits.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; // Parse patch results if ApplyPatch tool if let ToolResultPayload::Value { value } = payload && tool_call_id.contains("ApplyPatch") { let worktree_path_str = worktree_path.to_string_lossy(); if let Some(parsed) = parse_apply_patch_result(&value, &worktree_path_str) && let ActionType::FileEdit { changes, .. } = parsed { state.changes = changes; } } let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::CommandRun { tool_call_id } => { if let Some(mut state) = state.command_runs.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; match payload { ToolResultPayload::Value { value } => { let output = if let Some(s) = value.as_str() { s.to_string() } else { serde_json::to_string_pretty(&value) .unwrap_or_default() }; let exit_code = output .lines() .find(|line| { line.contains("[Process exited with code") }) .and_then(|line| { line.strip_prefix("[Process exited with code ")? .strip_suffix("]")? .parse::() .ok() }); state.output = output; state.exit_code = exit_code; if exit_code.is_some_and(|rc| rc != 0) { state.status = ToolStatus::Failed; } } ToolResultPayload::Error { error } => { state.output = error.message; } } let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::Todo { tool_call_id } => { if let Some(mut state) = state.todo_updates.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::Search { tool_call_id } => { if let Some(mut state) = state.searches.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::Fetch { tool_call_id } => { if let Some(mut state) = state.web_fetches.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } PendingToolCall::Generic { tool_call_id } => { if let Some(mut state) = state.generic_tools.remove(&tool_call_id) { state.status = if is_error { ToolStatus::Failed } else { ToolStatus::Success }; match payload { ToolResultPayload::Value { value } => { state.result = Some(value); } ToolResultPayload::Error { error } => { state.result = Some(error.message.into()); } } let entry = state.to_normalized_entry(); replace_normalized_entry( &msg_store, state.index.unwrap(), entry, ); } } } } } DroidJson::Completion { final_text, .. } => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::AssistantMessage, content: final_text.clone(), metadata: None, }; add_normalized_entry(&msg_store, &entry_index_provider, entry); sent_completion = true; } DroidJson::Error { message, .. } => { let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: message.clone(), metadata: None, }; add_normalized_entry(&msg_store, &state.entry_index, entry); } } } }); vec![h1, h2] } fn normalize_stderr_logs( msg_store: Arc, entry_index_provider: EntryIndexProvider, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stderr = msg_store.stderr_chunked_stream(); let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(Box::new(|content: String| NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content, metadata: None, })) .transform_lines(Box::new(|lines| { lines.iter_mut().for_each(|line| { *line = strip_ansi_escapes::strip_str(&line); // noisy, but seemingly harmless message happens when session is forked if line.starts_with("Error fetching session ") { line.clear(); } }); })) .time_gap(std::time::Duration::from_secs(2)) .index_provider(entry_index_provider) .build(); while let Some(Ok(chunk)) = stderr.next().await { for patch in processor.process(chunk) { msg_store.push_patch(patch); } } }) } /// Extract path from ApplyPatch input format fn extract_path_from_patch(input: &str) -> String { for line in input.lines() { if line.starts_with("*** Update File:") || line.starts_with("*** Add File:") { return line .split(':') .nth(1) .map(|s| s.trim().to_string()) .unwrap_or_default(); } } String::new() } /// Parse ApplyPatch result to extract file changes fn parse_apply_patch_result(value: &Value, worktree_path: &str) -> Option { let parsed_value; let result_obj = if value.is_object() { value } else if let Some(s) = value.as_str() { match serde_json::from_str::(s) { Ok(v) => { parsed_value = v; &parsed_value } Err(e) => { tracing::warn!( error = %e, input = %s, "Failed to parse apply_patch result string as JSON" ); return None; } } } else { tracing::warn!( value_type = ?value, "apply_patch result is neither object nor string" ); return None; }; let file_path = result_obj .get("file_path") .or_else(|| result_obj.get("value").and_then(|v| v.get("file_path"))) .and_then(|v: &Value| v.as_str()) .map(|s| s.to_string())?; let diff = result_obj .get("diff") .or_else(|| result_obj.get("value").and_then(|v| v.get("diff"))) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let content = result_obj .get("content") .or_else(|| result_obj.get("value").and_then(|v| v.get("content"))) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let relative_path = make_path_relative(&file_path, worktree_path); let changes = if let Some(diff_text) = diff { vec![FileChange::Edit { unified_diff: normalize_unified_diff(&relative_path, &diff_text), has_line_numbers: true, }] } else if let Some(content_text) = content { vec![FileChange::Write { content: content_text, }] } else { vec![] }; Some(ActionType::FileEdit { path: relative_path, changes, }) } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct ToolError { #[serde(rename = "type")] pub kind: String, pub message: String, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum ToolResultPayload { Value { value: Value }, Error { error: ToolError }, } pub struct EditToolResult {} #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DroidJson { System { #[serde(default)] subtype: Option, session_id: String, #[serde(default)] cwd: Option, #[serde(default)] tools: Option>, #[serde(default)] model: Option, }, Message { role: String, id: String, text: String, timestamp: u64, session_id: String, }, ToolCall { id: String, #[serde(rename = "messageId")] message_id: String, #[serde(rename = "toolId")] tool_id: String, #[serde(rename = "toolName")] tool_name: String, parameters: Value, timestamp: u64, session_id: String, }, ToolResult { #[serde(default)] id: Option, #[serde(rename = "messageId")] message_id: String, #[serde(rename = "toolId")] tool_id: String, #[serde(rename = "isError")] is_error: bool, #[serde(flatten)] payload: ToolResultPayload, timestamp: u64, session_id: String, }, Error { source: String, message: String, timestamp: u64, }, Completion { #[serde(rename = "finalText")] final_text: String, #[serde(default, rename = "numTurns")] num_turns: Option, #[serde(default, rename = "durationMs")] duration_ms: Option, #[serde(default)] timestamp: Option, session_id: String, }, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] struct DroidErrorLog { pub level: String, pub error: DroidErrorDetail, #[serde(default)] pub path: Option, #[serde(default)] pub tags: Option, #[serde(default)] pub msg: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] struct DroidErrorDetail { #[serde(default)] pub name: Option, pub message: String, #[serde(default)] pub stack: Option, } impl DroidJson { pub fn session_id(&self) -> Option<&str> { match self { DroidJson::System { .. } => None, // session might not have been initialized yet DroidJson::Message { session_id, .. } => Some(session_id), DroidJson::ToolCall { session_id, .. } => Some(session_id), DroidJson::ToolResult { session_id, .. } => Some(session_id), DroidJson::Completion { session_id, .. } => Some(session_id), DroidJson::Error { .. } => None, } } } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] #[serde(tag = "toolName", content = "parameters")] pub enum DroidToolData { Read { #[serde(alias = "path")] file_path: String, }, LS { directory_path: String, #[serde(default)] #[serde(rename = "ignorePatterns")] ignore_patterns: Option>, }, Glob { folder: String, patterns: Vec, #[serde(default)] #[serde(rename = "excludePatterns")] exclude_patterns: Option>, }, Grep { pattern: String, #[serde(default)] path: Option, #[serde(default)] #[serde(rename = "caseSensitive")] case_sensitive: Option, }, Execute { command: String, #[serde(default)] timeout: Option, #[serde(default)] #[serde(rename = "riskLevel")] risk_level: Option, }, Edit { #[serde(alias = "path")] file_path: String, #[serde(alias = "old_str")] old_string: String, #[serde(alias = "new_str")] new_string: String, }, MultiEdit { #[serde(alias = "path")] file_path: String, #[serde(alias = "changes")] edits: Vec, }, Create { #[serde(alias = "path")] file_path: String, content: String, }, ApplyPatch { input: String, }, TodoWrite { todos: Vec, }, WebSearch { query: String, #[serde(default)] max_results: Option, }, FetchUrl { url: String, #[serde(default)] method: Option, }, ExitSpecMode { #[serde(default)] reason: Option, }, #[serde(rename = "slack_post_message")] SlackPostMessage { channel: String, text: String, }, #[serde(untagged)] Unknown { #[serde(flatten)] data: std::collections::HashMap, }, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct DroidTodoItem { #[serde(default)] pub id: Option, pub content: String, pub status: String, #[serde(default)] pub priority: Option, } #[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct DroidEditItem { pub old_string: Option, pub new_string: Option, } trait ToNormalizedEntry { fn to_normalized_entry(&self) -> NormalizedEntry; } #[derive(Debug, Clone)] struct FileReadState { index: Option, path: String, status: ToolStatus, } impl ToNormalizedEntry for FileReadState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "read".to_string(), action_type: ActionType::FileRead { path: self.path.clone(), }, status: self.status.clone(), }, content: self.path.clone(), metadata: None, } } } #[derive(Debug, Clone)] struct FileEditState { index: Option, path: String, changes: Vec, status: ToolStatus, } impl ToNormalizedEntry for FileEditState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "edit".to_string(), action_type: ActionType::FileEdit { path: self.path.clone(), changes: self.changes.clone(), }, status: self.status.clone(), }, content: self.path.clone(), metadata: None, } } } #[derive(Debug, Clone)] struct CommandRunState { index: Option, command: String, output: String, status: ToolStatus, exit_code: Option, } impl ToNormalizedEntry for CommandRunState { fn to_normalized_entry(&self) -> NormalizedEntry { let result = if self.output.is_empty() && self.exit_code.is_none() { None } else { Some(CommandRunResult { exit_status: self .exit_code .map(|code| CommandExitStatus::ExitCode { code }), output: if self.output.is_empty() { None } else { Some(self.output.clone()) }, }) }; NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "bash".to_string(), action_type: ActionType::CommandRun { command: self.command.clone(), result, category: CommandCategory::from_command(&self.command), }, status: self.status.clone(), }, content: self.command.clone(), metadata: None, } } } #[derive(Debug, Clone)] struct TodoManagementState { index: Option, todos: Vec, status: ToolStatus, } impl ToNormalizedEntry for TodoManagementState { fn to_normalized_entry(&self) -> NormalizedEntry { let content = if self.todos.is_empty() { "TODO list updated".to_string() } else { format!("TODO list updated ({} items)", self.todos.len()) }; NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "todo".to_string(), action_type: ActionType::TodoManagement { todos: self.todos.clone(), operation: "update".to_string(), }, status: self.status.clone(), }, content, metadata: None, } } } #[derive(Debug, Clone)] struct SearchState { index: Option, query: String, status: ToolStatus, } impl ToNormalizedEntry for SearchState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "search".to_string(), action_type: ActionType::Search { query: self.query.clone(), }, status: self.status.clone(), }, content: self.query.clone(), metadata: None, } } } #[derive(Debug, Clone)] struct WebFetchState { index: Option, url: String, status: ToolStatus, } impl ToNormalizedEntry for WebFetchState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "fetch".to_string(), action_type: ActionType::WebFetch { url: self.url.clone(), }, status: self.status.clone(), }, content: self.url.clone(), metadata: None, } } } #[derive(Debug, Clone)] struct GenericToolState { index: Option, name: String, arguments: Option, status: ToolStatus, result: Option, } impl ToNormalizedEntry for GenericToolState { fn to_normalized_entry(&self) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: self.name.clone(), action_type: ActionType::Tool { tool_name: self.name.clone(), arguments: self.arguments.clone(), result: self.result.clone().map(|value| { if let Some(str) = value.as_str() { ToolResult::markdown(str) } else { ToolResult::json(value) } }), }, status: self.status.clone(), }, content: self.name.clone(), metadata: None, } } } type ToolCallId = String; #[derive(Debug, Clone)] enum PendingToolCall { Read { tool_call_id: ToolCallId }, FileEdit { tool_call_id: ToolCallId }, CommandRun { tool_call_id: ToolCallId }, Todo { tool_call_id: ToolCallId }, Search { tool_call_id: ToolCallId }, Fetch { tool_call_id: ToolCallId }, Generic { tool_call_id: ToolCallId }, } // Tracks tool-calls from creation to completion updating tool arguments and results as they come in #[derive(Debug, Clone)] struct ToolCallStates { entry_index: EntryIndexProvider, file_reads: HashMap, file_edits: HashMap, command_runs: HashMap, todo_updates: HashMap, searches: HashMap, web_fetches: HashMap, generic_tools: HashMap, pending_fifo: VecDeque, model_reported: bool, } impl ToolCallStates { fn new(entry_index: EntryIndexProvider) -> Self { Self { entry_index, file_reads: HashMap::new(), file_edits: HashMap::new(), command_runs: HashMap::new(), todo_updates: HashMap::new(), searches: HashMap::new(), web_fetches: HashMap::new(), generic_tools: HashMap::new(), pending_fifo: VecDeque::new(), model_reported: false, } } } ================================================ FILE: crates/executors/src/executors/droid.rs ================================================ use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum_macros::AsRefStr; use tokio::{io::AsyncWriteExt, process::Command}; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore}; use crate::{ command::{CommandBuildError, CommandBuilder, CommandParts}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, }, logs::utils::{EntryIndexProvider, patch}, model_selector::{ModelInfo, ModelSelectorConfig}, profile::ExecutorConfig, }; pub mod normalize_logs; use normalize_logs::normalize_logs; // Configuration types for Droid executor #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, TS, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Autonomy { Normal, Low, Medium, High, SkipPermissionsUnsafe, } fn default_autonomy() -> Autonomy { Autonomy::SkipPermissionsUnsafe } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema, AsRefStr)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] #[ts(rename = "DroidReasoningEffort")] pub enum ReasoningEffortLevel { None, Dynamic, Off, Low, Medium, High, } /// Droid executor configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] pub struct Droid { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default = "default_autonomy")] #[schemars( title = "Autonomy Level", description = "Permission level for file and system operations" )] pub autonomy: Autonomy, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars( title = "Model", description = "Model to use (e.g., gpt-5-codex, claude-sonnet-4-5-20250929, gpt-5-2025-08-07, claude-opus-4-1-20250805, claude-haiku-4-5-20251001, glm-4.6)" )] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars( title = "Reasoning Effort", description = "Reasoning effort level: none, dynamic, off, low, medium, high" )] pub reasoning_effort: Option, #[serde(flatten)] pub cmd: crate::command::CmdOverrides, } impl Droid { pub fn build_command_builder(&self) -> Result { use crate::command::{CommandBuilder, apply_overrides}; let mut builder = CommandBuilder::new("droid exec").params(["--output-format", "stream-json"]); builder = match &self.autonomy { Autonomy::Normal => builder, Autonomy::Low => builder.extend_params(["--auto", "low"]), Autonomy::Medium => builder.extend_params(["--auto", "medium"]), Autonomy::High => builder.extend_params(["--auto", "high"]), Autonomy::SkipPermissionsUnsafe => builder.extend_params(["--skip-permissions-unsafe"]), }; if let Some(model) = &self.model { builder = builder.extend_params(["--model", model.as_str()]); } if let Some(effort) = &self.reasoning_effort { builder = builder.extend_params(["--reasoning-effort", effort.as_ref()]); } apply_overrides(builder, &self.cmd) } } async fn spawn_droid( command_parts: CommandParts, prompt: &String, current_dir: &Path, env: &ExecutionEnv, cmd_overrides: &crate::command::CmdOverrides, ) -> Result { let (program_path, args) = command_parts.into_resolved().await?; let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .args(args); env.clone() .with_profile(cmd_overrides) .apply_to_command(&mut command); let mut child = command.group_spawn_no_window()?; if let Some(mut stdin) = child.inner().stdin.take() { stdin.write_all(prompt.as_bytes()).await?; stdin.shutdown().await?; } Ok(child.into()) } #[async_trait] impl StandardCodingAgentExecutor for Droid { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(permission_policy) = executor_config.permission_policy.clone() { self.autonomy = match permission_policy { crate::model_selector::PermissionPolicy::Auto => Autonomy::SkipPermissionsUnsafe, crate::model_selector::PermissionPolicy::Supervised | crate::model_selector::PermissionPolicy::Plan => Autonomy::Normal, }; } } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let droid_command = self.build_command_builder()?.build_initial()?; let combined_prompt = self.append_prompt.combine_prompt(prompt); spawn_droid(droid_command, &combined_prompt, current_dir, env, &self.cmd).await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let continue_cmd = self .build_command_builder()? .build_follow_up(&["--session-id".to_string(), session_id.to_string()])?; let combined_prompt = self.append_prompt.combine_prompt(prompt); spawn_droid(continue_cmd, &combined_prompt, current_dir, env, &self.cmd).await } fn normalize_logs( &self, msg_store: Arc, current_dir: &Path, ) -> Vec> { normalize_logs( msg_store.clone(), current_dir, EntryIndexProvider::start_from(&msg_store), ) } fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".factory").join("mcp.json")) } fn get_availability_info(&self) -> AvailabilityInfo { let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); let installation_indicator_found = dirs::home_dir() .map(|home| home.join(".factory").join("installation_id").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::Droid, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: self .reasoning_effort .as_ref() .map(|e| e.as_ref().to_string()), permission_policy: Some(crate::model_selector::PermissionPolicy::Auto), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { models: [ ("claude-opus-4-6", "Claude Opus 4.6"), ("claude-opus-4-6-fast", "Claude Opus 4.6 Fast Mode"), ("gemini-3.1-pro-preview", "Gemini 3.1 Pro"), ("glm-5", "GLM-5"), ("gpt-5.3-codex", "GPT 5.3 Codex"), ("claude-sonnet-4-6", "Claude Sonnet 4.6"), ("kimi-k2.5", "Kimi K2.5"), ("minimax-m2.5", "MiniMax M2.5"), ("glm-4.7", "GLM-4.7"), ("claude-opus-4-5-20251101", "Claude Opus 4.5"), ("claude-sonnet-4-5-20250929", "Claude Sonnet 4.5"), ("claude-haiku-4-5-20251001", "Claude Haiku 4.5"), ("gpt-5.2-codex", "GPT 5.2 Codex"), ("gpt-5.2", "GPT 5.2"), ("gemini-3-pro-preview", "Gemini 3 Pro"), ("gemini-3-flash-preview", "Gemini 3 Flash"), ("gpt-5.1-codex", "GPT 5.1 Codex"), ("gpt-5.1-codex-max", "GPT 5.1 Codex Max"), ("gpt-5.1", "GPT 5.1"), ] .into_iter() .map(|(id, name)| ModelInfo { id: id.to_string(), name: name.to_string(), provider_id: None, reasoning_options: vec![], }) .collect(), ..Default::default() }, ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } } ================================================ FILE: crates/executors/src/executors/gemini.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use derivative::Derivative; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use workspace_utils::msg_store::MsgStore; pub use super::acp::AcpAgentHarness; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, }, logs::utils::patch, model_selector::{ModelInfo, ModelSelectorConfig, PermissionPolicy}, profile::ExecutorConfig, }; const SUPPRESSED_STDERR_PATTERNS: &[&str] = &[ "was started but never ended. Skipping metrics.", "YOLO mode is enabled. All tool calls will be automatically approved.", ]; #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct Gemini { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub yolo: Option, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub approvals: Option>, } impl Gemini { fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new("npx -y @google/gemini-cli@0.29.3"); if let Some(model) = &self.model { builder = builder.extend_params(["--model", model.as_str()]); } if self.yolo.unwrap_or(false) { builder = builder.extend_params(["--yolo"]); builder = builder.extend_params(["--allowed-tools", "run_shell_command"]); } builder = builder.extend_params(["--experimental-acp"]); apply_overrides(builder, &self.cmd) } } #[async_trait] impl StandardCodingAgentExecutor for Gemini { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(permission_policy) = executor_config.permission_policy.clone() { self.yolo = Some(matches!( permission_policy, crate::model_selector::PermissionPolicy::Auto )); } } fn use_approvals(&mut self, approvals: Arc) { self.approvals = Some(approvals); } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let harness = AcpAgentHarness::new(); let combined_prompt = self.append_prompt.combine_prompt(prompt); let gemini_command = self.build_command_builder()?.build_initial()?; let approvals = if self.yolo.unwrap_or(false) { None } else { self.approvals.clone() }; harness .spawn_with_command( current_dir, combined_prompt, gemini_command, env, &self.cmd, approvals, ) .await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let harness = AcpAgentHarness::new(); let combined_prompt = self.append_prompt.combine_prompt(prompt); let gemini_command = self.build_command_builder()?.build_follow_up(&[])?; let approvals = if self.yolo.unwrap_or(false) { None } else { self.approvals.clone() }; harness .spawn_follow_up_with_command( current_dir, combined_prompt, session_id, gemini_command, env, &self.cmd, approvals, ) .await } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { super::acp::normalize_logs_with_suppressed_stderr_patterns( msg_store, worktree_path, SUPPRESSED_STDERR_PATTERNS, ) } fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".gemini").join("settings.json")) } fn get_availability_info(&self) -> AvailabilityInfo { if let Some(timestamp) = dirs::home_dir() .and_then(|home| std::fs::metadata(home.join(".gemini").join("oauth_creds.json")).ok()) .and_then(|m| m.modified().ok()) .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) { return AvailabilityInfo::LoginDetected { last_auth_timestamp: timestamp, }; } let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); let installation_indicator_found = dirs::home_dir() .map(|home| home.join(".gemini").join("installation_id").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { use crate::model_selector::*; ExecutorConfig { executor: BaseCodingAgent::Gemini, variant: None, model_id: self.model.clone(), agent_id: None, reasoning_id: None, permission_policy: Some(if self.yolo.unwrap_or(false) { PermissionPolicy::Auto } else { PermissionPolicy::Supervised }), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { models: vec![ ModelInfo { id: "gemini-3.1-pro-preview".to_string(), name: "Gemini 3.1 Pro Preview".to_string(), provider_id: None, reasoning_options: vec![], }, ModelInfo { id: "gemini-3-pro-preview".to_string(), name: "Gemini 3 Pro".to_string(), provider_id: None, reasoning_options: vec![], }, ModelInfo { id: "gemini-3-flash-preview".to_string(), name: "Gemini 3 Flash".to_string(), provider_id: None, reasoning_options: vec![], }, ], default_model: Some("gemini-3-pro-preview".to_string()), permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised], ..Default::default() }, ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } } ================================================ FILE: crates/executors/src/executors/mod.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use command_group::AsyncGroupChild; use enum_dispatch::enum_dispatch; use futures::stream::BoxStream; use futures_io::Error as FuturesIoError; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sqlx::Type; use strum_macros::{Display, EnumDiscriminants, EnumString, VariantNames}; use thiserror::Error; use tokio::task::JoinHandle; use ts_rs::TS; use workspace_utils::msg_store::MsgStore; #[cfg(feature = "qa-mode")] use crate::executors::qa_mock::QaMockExecutor; use crate::{ actions::{ExecutorAction, review::RepoReviewContext}, approvals::ExecutorApprovalService, command::CommandBuildError, env::ExecutionEnv, executors::{ amp::Amp, claude::ClaudeCode, codex::Codex, copilot::Copilot, cursor::CursorAgent, droid::Droid, gemini::Gemini, opencode::Opencode, qwen::QwenCode, }, logs::utils::patch, mcp_config::McpConfig, profile::ExecutorConfig, }; pub mod acp; pub mod amp; pub mod claude; pub mod codex; pub mod copilot; pub mod cursor; pub mod droid; pub mod gemini; pub mod opencode; #[cfg(feature = "qa-mode")] pub mod qa_mock; pub mod qwen; pub mod utils; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] pub struct SlashCommandDescription { /// Command name without the leading slash, e.g. `help` for `/help`. pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[ts(use_ts_enum)] pub enum BaseAgentCapability { SessionFork, /// Agent requires a setup script before it can run (e.g., login, installation) SetupHelper, /// Agent reports context/token usage information ContextUsage, } #[derive(Debug, Error)] pub enum ExecutorError { #[error("Follow-up is not supported: {0}")] FollowUpNotSupported(String), #[error(transparent)] SpawnError(#[from] FuturesIoError), #[error("Unknown executor type: {0}")] UnknownExecutorType(String), #[error("I/O error: {0}")] Io(std::io::Error), #[error(transparent)] Json(#[from] serde_json::Error), #[error(transparent)] TomlSerialize(#[from] toml::ser::Error), #[error(transparent)] TomlDeserialize(#[from] toml::de::Error), #[error(transparent)] ExecutorApprovalError(#[from] crate::approvals::ExecutorApprovalError), #[error(transparent)] CommandBuild(#[from] CommandBuildError), #[error("Executable `{program}` not found in PATH")] ExecutableNotFound { program: String }, #[error("Setup helper not supported")] SetupHelperNotSupported, #[error("Auth required: {0}")] AuthRequired(String), } #[enum_dispatch] #[derive( Debug, Clone, Serialize, Deserialize, PartialEq, TS, Display, EnumDiscriminants, VariantNames, )] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] #[strum_discriminants( name(BaseCodingAgent), // Only add Hash; Eq/PartialEq are already provided by EnumDiscriminants. derive(EnumString, Hash, strum_macros::Display, Serialize, Deserialize, TS, Type), strum(serialize_all = "SCREAMING_SNAKE_CASE"), ts(use_ts_enum), serde(rename_all = "SCREAMING_SNAKE_CASE"), sqlx(type_name = "TEXT", rename_all = "SCREAMING_SNAKE_CASE") )] pub enum CodingAgent { ClaudeCode, Amp, Gemini, Codex, Opencode, #[serde(alias = "CURSOR")] #[strum_discriminants(serde(alias = "CURSOR"))] #[strum_discriminants(strum(serialize = "CURSOR", serialize = "CURSOR_AGENT"))] CursorAgent, QwenCode, Copilot, Droid, #[cfg(feature = "qa-mode")] QaMock(QaMockExecutor), } impl CodingAgent { pub fn get_mcp_config(&self) -> McpConfig { match self { Self::Codex(_) => McpConfig::new( vec!["mcp_servers".to_string()], serde_json::json!({ "mcp_servers": {} }), self.preconfigured_mcp(), true, ), Self::Amp(_) => McpConfig::new( vec!["amp.mcpServers".to_string()], serde_json::json!({ "amp.mcpServers": {} }), self.preconfigured_mcp(), false, ), Self::Opencode(_) => McpConfig::new( vec!["mcp".to_string()], serde_json::json!({ "mcp": {}, "$schema": "https://opencode.ai/config.json" }), self.preconfigured_mcp(), false, ), Self::Droid(_) => McpConfig::new( vec!["mcpServers".to_string()], serde_json::json!({ "mcpServers": {} }), self.preconfigured_mcp(), false, ), _ => McpConfig::new( vec!["mcpServers".to_string()], serde_json::json!({ "mcpServers": {} }), self.preconfigured_mcp(), false, ), } } pub fn supports_mcp(&self) -> bool { self.default_mcp_config_path().is_some() } pub fn capabilities(&self) -> Vec { match self { Self::ClaudeCode(_) => vec![ BaseAgentCapability::SessionFork, BaseAgentCapability::ContextUsage, ], Self::Opencode(_) => vec![ BaseAgentCapability::SessionFork, BaseAgentCapability::ContextUsage, ], Self::Codex(_) => vec![ BaseAgentCapability::SessionFork, BaseAgentCapability::SetupHelper, BaseAgentCapability::ContextUsage, ], Self::Gemini(_) | Self::QwenCode(_) => { vec![BaseAgentCapability::SessionFork] } Self::CursorAgent(_) => vec![BaseAgentCapability::SetupHelper], Self::Amp(_) | Self::Copilot(_) | Self::Droid(_) => vec![], #[cfg(feature = "qa-mode")] Self::QaMock(_) => vec![], // QA mock doesn't need special capabilities } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] pub enum AvailabilityInfo { LoginDetected { last_auth_timestamp: i64 }, InstallationFound, NotFound, } impl AvailabilityInfo { pub fn is_available(&self) -> bool { matches!( self, AvailabilityInfo::LoginDetected { .. } | AvailabilityInfo::InstallationFound ) } } #[async_trait] #[enum_dispatch(CodingAgent)] pub trait StandardCodingAgentExecutor { fn apply_overrides(&mut self, _executor_config: &ExecutorConfig) {} fn use_approvals(&mut self, _approvals: Arc) {} async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result; /// Continue a session, optionally resetting to a specific message. async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result; async fn spawn_review( &self, current_dir: &Path, prompt: &str, session_id: Option<&str>, env: &ExecutionEnv, ) -> Result { match session_id { Some(id) => { self.spawn_follow_up(current_dir, prompt, id, None, env) .await } None => self.spawn(current_dir, prompt, env).await, } } fn normalize_logs( &self, _raw_logs_event_store: Arc, _worktree_path: &Path, ) -> Vec> { vec![] } // MCP configuration methods fn default_mcp_config_path(&self) -> Option; async fn get_setup_helper_action(&self) -> Result { Err(ExecutorError::SetupHelperNotSupported) } fn get_availability_info(&self) -> AvailabilityInfo { let config_files_found = self .default_mcp_config_path() .map(|path| path.exists()) .unwrap_or(false); if config_files_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } /// Returns a stream of executor discovered options updates. async fn discover_options( &self, _workdir: Option<&Path>, _repo_path: Option<&Path>, ) -> Result, ExecutorError> { let options = crate::executor_discovery::ExecutorDiscoveredOptions::default(); Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } /// Returns the default overrides defined by this preset/variant. fn get_preset_options(&self) -> ExecutorConfig; } /// Result communicated through the exit signal #[derive(Debug, Clone, Copy)] pub enum ExecutorExitResult { /// Process completed successfully (exit code 0) Success, /// Process should be marked as failed (non-zero exit) Failure, } /// Optional exit notification from an executor. /// When this receiver resolves, the container should gracefully stop the process /// and mark it according to the result. pub type ExecutorExitSignal = tokio::sync::oneshot::Receiver; /// Cancellation token for requesting graceful shutdown of an executor. /// When cancelled, the executor should attempt to cancel gracefully before being killed. pub type CancellationToken = tokio_util::sync::CancellationToken; #[derive(Debug)] pub struct SpawnedChild { pub child: AsyncGroupChild, /// Executor → Container: signals when executor wants to exit pub exit_signal: Option, /// Container → Executor: signals when container wants to cancel the execution pub cancel: Option, } impl From for SpawnedChild { fn from(child: AsyncGroupChild) -> Self { Self { child, exit_signal: None, cancel: None, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)] #[serde(transparent)] #[schemars( title = "Append Prompt", description = "Extra text appended to the prompt", extend("format" = "textarea") )] #[derive(Default)] pub struct AppendPrompt(pub Option); impl AppendPrompt { pub fn get(&self) -> Option { self.0.clone() } pub fn combine_prompt(&self, prompt: &str) -> String { match self { AppendPrompt(Some(value)) => format!("{prompt}{value}"), AppendPrompt(None) => prompt.to_string(), } } } pub fn build_review_prompt( context: Option<&[RepoReviewContext]>, additional_prompt: Option<&str>, ) -> String { let mut prompt = String::from("Please review the code changes.\n\n"); if let Some(repos) = context { for repo in repos { prompt.push_str(&format!("Repository: {}\n", repo.repo_name)); prompt.push_str(&format!( "Review all changes from base commit {} to HEAD.\n", repo.base_commit )); prompt.push_str(&format!( "Use `git diff {}..HEAD` to see the changes.\n", repo.base_commit )); prompt.push('\n'); } } if let Some(additional) = additional_prompt { prompt.push_str(additional); } prompt } #[cfg(test)] mod tests { use std::str::FromStr; use super::*; #[test] fn test_cursor_agent_deserialization() { // Test that CURSOR_AGENT is accepted let result = BaseCodingAgent::from_str("CURSOR_AGENT"); assert!(result.is_ok(), "CURSOR_AGENT should be valid"); assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent); // Test that legacy CURSOR is still accepted for backwards compatibility let result = BaseCodingAgent::from_str("CURSOR"); assert!( result.is_ok(), "CURSOR should be valid for backwards compatibility" ); assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent); // Test serde deserialization for CURSOR_AGENT let result: Result = serde_json::from_str(r#""CURSOR_AGENT""#); assert!(result.is_ok(), "CURSOR_AGENT should deserialize via serde"); assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent); // Test serde deserialization for legacy CURSOR let result: Result = serde_json::from_str(r#""CURSOR""#); assert!(result.is_ok(), "CURSOR should deserialize via serde"); assert_eq!(result.unwrap(), BaseCodingAgent::CursorAgent); } } ================================================ FILE: crates/executors/src/executors/opencode/models.rs ================================================ use std::{ collections::{HashMap, HashSet}, sync::{LazyLock, Mutex}, }; use serde_json::Value; use crate::executors::opencode::{ sdk::{EventStreamContext, list_providers}, types::{MessageRole, OpencodeExecutorEvent, ProviderListResponse, SdkEvent}, }; type ProviderId = String; type ModelId = String; type ContextWindowTokens = u32; // Maps (Provider, Model) -> Context Window pub(super) type ModelContextWindows = HashMap<(ProviderId, ModelId), ContextWindowTokens>; /// Cache entry for model context windows. /// Keyed by a config-derived cache key (based on env vars + base command) /// rather than directory, since configuration determines available models. #[derive(Default)] struct ModelCacheEntry { context_windows: ModelContextWindows, /// Negative cache for models that were requested but not found. /// Prevents repeated API calls for models that don't return context info. unknown_models: HashSet<(ProviderId, ModelId)>, } struct ModelContextCache { entries: Mutex>, } impl ModelContextCache { fn new() -> Self { Self { entries: Mutex::new(HashMap::new()), } } fn get(&self, cache_key: &str, provider: &str, model: &str) -> Option { let map = self.entries.lock().unwrap(); let entry = map.get(cache_key)?; entry .context_windows .get(&(provider.to_string(), model.to_string())) .copied() .or_else(|| { entry .unknown_models .contains(&(provider.to_string(), model.to_string())) .then_some(0) }) } fn update( &self, cache_key: &str, provider: &str, model: &str, fetched_windows: ModelContextWindows, ) -> u32 { let mut cache = self.entries.lock().unwrap(); let entry = cache.entry(cache_key.to_string()).or_default(); entry.context_windows.extend(fetched_windows); entry .unknown_models .retain(|key| !entry.context_windows.contains_key(key)); entry .context_windows .get(&(provider.to_string(), model.to_string())) .copied() .unwrap_or_else(|| { entry .unknown_models .insert((provider.to_string(), model.to_string())); 0 }) } } static CONTEXT_WINDOWS_CACHE: LazyLock = LazyLock::new(ModelContextCache::new); async fn get_model_context_window( client: &reqwest::Client, base_url: &str, directory: &str, cache_key: &str, provider_id: &str, model_id: &str, ) -> u32 { if let Some(cached) = CONTEXT_WINDOWS_CACHE.get(cache_key, provider_id, model_id) { return cached; } let Some(fetched) = fetch_model_context_windows(client, base_url, directory).await else { return 0; }; CONTEXT_WINDOWS_CACHE.update(cache_key, provider_id, model_id, fetched) } pub(super) async fn maybe_emit_token_usage(context: &EventStreamContext<'_>, event: &Value) { let Some(SdkEvent::MessageUpdated(event)) = SdkEvent::parse(event) else { return; }; let message = event.info; if message.role != MessageRole::Assistant { return; } let Some(ref tokens) = message.tokens else { return; }; let total_tokens = tokens.input + tokens.output + tokens.cache.as_ref().map(|c| c.read).unwrap_or(0); if total_tokens == 0 { return; } let provider_id = message.provider_id(); let model_id = message.model_id(); let model_context_window = match (provider_id, model_id) { (Some(provider), Some(model)) => { get_model_context_window( context.client, context.base_url, context.directory, context.models_cache_key, provider, model, ) .await } _ => 0, }; if model_context_window == 0 { return; } let _ = context .log_writer .log_event(&OpencodeExecutorEvent::TokenUsage { total_tokens, model_context_window, }) .await; } pub(super) fn extract_context_windows(data: &ProviderListResponse) -> ModelContextWindows { let mut windows = ModelContextWindows::new(); for provider in &data.all { for (model_id, info) in &provider.models { if info.limit.context > 0 { windows.insert((provider.id.clone(), model_id.clone()), info.limit.context); } } } windows } pub(super) fn seed_context_windows_cache(cache_key: &str, windows: ModelContextWindows) { let mut cache = CONTEXT_WINDOWS_CACHE.entries.lock().unwrap(); let entry = cache.entry(cache_key.to_string()).or_default(); entry.context_windows.extend(windows); } async fn fetch_model_context_windows( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Option { let parsed = match list_providers(client, base_url, directory).await { Ok(p) => p, Err(err) => { tracing::debug!("OpenCode provider list request failed: {err}"); return None; } }; Some(extract_context_windows(&parsed)) } ================================================ FILE: crates/executors/src/executors/opencode/normalize_logs.rs ================================================ use std::{collections::HashMap, path::Path, sync::Arc}; type MessageId = String; type PartId = String; use futures::StreamExt; use serde::Deserialize; use serde_json::Value; use workspace_utils::{ approvals::{ApprovalStatus, QuestionStatus}, msg_store::MsgStore, path::make_path_relative, }; use super::types::{ MessageInfo, MessagePartDeltaEvent, MessageRole, OpencodeExecutorEvent, Part, PermissionAskedEvent, QuestionInfo, SdkEvent, SdkTodo, SessionStatus, ToolPart, ToolStateUpdate, }; use crate::{ approvals::ToolCallMetadata, logs::{ ActionType, AnsweredQuestion, AskUserQuestionItem, AskUserQuestionOption, CommandExitStatus, CommandRunResult, FileChange, NormalizedEntry, NormalizedEntryError, NormalizedEntryType, TodoItem, TokenUsageInfo, ToolResult, ToolStatus, stderr_processor::normalize_stderr_logs, utils::{ EntryIndexProvider, patch::{add_normalized_entry, replace_normalized_entry, upsert_normalized_entry}, shell_command_parsing::CommandCategory, }, }, }; fn system_message(content: String) -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content, metadata: None, } } pub fn normalize_logs( msg_store: Arc, worktree_path: &Path, ) -> Vec> { let entry_index = EntryIndexProvider::start_from(&msg_store); let h1 = normalize_stderr_logs(msg_store.clone(), entry_index.clone()); let worktree_path = worktree_path.to_path_buf(); let h2 = tokio::spawn(async move { let mut stored_session_id = false; let mut state = LogState::new(entry_index.clone(), msg_store.clone()); let mut stdout_lines = msg_store.stdout_lines_stream(); while let Some(Ok(line)) = stdout_lines.next().await { let Some(event) = parse_event(&line) else { let trimmed = line.trim(); if trimmed.is_empty() { continue; } add_normalized_entry( &msg_store, &entry_index, system_message(trimmed.to_string()), ); continue; }; match event { OpencodeExecutorEvent::StartupLog { .. } => {} OpencodeExecutorEvent::SessionStart { session_id } => { if !stored_session_id { msg_store.push_session_id(session_id); stored_session_id = true; } } OpencodeExecutorEvent::SdkEvent { event } => { state.handle_sdk_event(&event, &worktree_path, &msg_store); } OpencodeExecutorEvent::TokenUsage { total_tokens, model_context_window, } => { add_normalized_entry( &msg_store, &entry_index, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::TokenUsageInfo(TokenUsageInfo { total_tokens, model_context_window, }), content: format!( "Tokens used: {} / Context window: {}", total_tokens, model_context_window ), metadata: None, }, ); } OpencodeExecutorEvent::SlashCommandResult { message } => { let idx = entry_index.next(); state.add_normalized_entry_with_index( idx, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::AssistantMessage, content: message, metadata: None, }, ); } OpencodeExecutorEvent::ApprovalRequested { tool_call_id, approval_id, } | OpencodeExecutorEvent::QuestionAsked { tool_call_id, approval_id, } => { state.handle_approval_requested( &tool_call_id, approval_id, &worktree_path, &msg_store, ); } OpencodeExecutorEvent::ApprovalResponse { tool_call_id, status, } => { state.handle_approval_response( &tool_call_id, status, &worktree_path, &msg_store, ); } OpencodeExecutorEvent::QuestionResponse { tool_call_id, status, } => { state.handle_question_response( &tool_call_id, status, &worktree_path, &msg_store, ); } OpencodeExecutorEvent::SystemMessage { content } => { let idx = entry_index.next(); msg_store.push_patch( crate::logs::utils::ConversationPatch::add_normalized_entry( idx, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content, metadata: None, }, ), ); } OpencodeExecutorEvent::Error { message } => { let idx = entry_index.next(); msg_store.push_patch( crate::logs::utils::ConversationPatch::add_normalized_entry( idx, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: message, metadata: None, }, ), ); } OpencodeExecutorEvent::Done => {} } } }); vec![h1, h2] } fn parse_event(line: &str) -> Option { serde_json::from_str::(line.trim()).ok() } #[derive(Debug, Clone)] struct StreamingText { index: usize, content: String, } #[derive(Debug, Clone)] enum UpdateMode { Append, Set, } #[derive(Debug, Clone, Copy)] enum PartKind { AssistantText, Thinking, } #[derive(Default)] struct LogState { entry_index: EntryIndexProvider, msg_store: Arc, message_roles: HashMap, assistant_text: HashMap, thinking_text: HashMap, part_kinds: HashMap, pending_deltas: HashMap>, tool_states: HashMap, approvals: HashMap, model_system_message_emitted: bool, todo_update_entry: Option, todo_update_fingerprint: Option, retry_status_fingerprint: Option, } impl LogState { fn new(entry_index: EntryIndexProvider, msg_store: Arc) -> Self { Self { entry_index, msg_store, message_roles: HashMap::new(), assistant_text: HashMap::new(), thinking_text: HashMap::new(), part_kinds: HashMap::new(), pending_deltas: HashMap::new(), tool_states: HashMap::new(), approvals: HashMap::new(), model_system_message_emitted: false, todo_update_entry: None, todo_update_fingerprint: None, retry_status_fingerprint: None, } } fn handle_sdk_event(&mut self, raw: &Value, worktree_path: &Path, msg_store: &Arc) { let Some(event) = SdkEvent::parse(raw) else { let raw_text = raw.to_string(); if !raw_text.trim().is_empty() { self.add_normalized_entry(system_message(format!( "Unrecognized OpenCode SDK event: {raw_text}" ))); } return; }; match event { SdkEvent::MessageUpdated(event) => { let info = event.info; self.maybe_emit_model_system_message(&info); self.message_roles.insert(info.id, info.role); } SdkEvent::MessagePartUpdated(event) => { self.handle_part_update( event.part, event.delta.as_deref(), worktree_path, msg_store, ); } SdkEvent::MessagePartDelta(event) => { self.handle_part_delta(event, msg_store); } SdkEvent::TodoUpdated(event) => { self.handle_todo_updated(&event.todos, msg_store); } SdkEvent::SessionStatus(event) => { self.handle_session_status(event.status); } SdkEvent::SessionIdle => {} SdkEvent::SessionCompacted => { self.add_normalized_entry(system_message("Session compacted".to_string())); } SdkEvent::PermissionAsked(event) => { self.handle_permission_asked(event, worktree_path, msg_store); } SdkEvent::QuestionAsked(event) => { self.handle_question_asked(event, worktree_path, msg_store); } SdkEvent::PermissionReplied | SdkEvent::MessageRemoved | SdkEvent::MessagePartRemoved | SdkEvent::QuestionReplied | SdkEvent::QuestionRejected | SdkEvent::CommandExecuted | SdkEvent::SessionDiff | SdkEvent::TuiSessionSelect => {} SdkEvent::SessionError(event) => { let (error_type, message) = match event.error { Some(err) if err.kind() == "ProviderAuthError" => ( NormalizedEntryError::SetupRequired, err.message() .unwrap_or_else(|| format!("OpenCode session error: {}", err.raw)), ), Some(err) => ( NormalizedEntryError::Other, format!("OpenCode session error: {}", err.raw), ), None => ( NormalizedEntryError::Other, "OpenCode session error".to_string(), ), }; let idx = self.entry_index.next(); self.add_normalized_entry_with_index( idx, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type }, content: message, metadata: None, }, ); } SdkEvent::Unknown { type_, properties } => { self.add_normalized_entry(system_message(format!( "Unrecognized OpenCode SDK event type `{type_}`: {properties}" ))); } } } fn handle_session_status(&mut self, status: SessionStatus) { match status { SessionStatus::Retry { attempt, message, next, } => { let fingerprint = format!("{attempt}:{next}:{message}"); if self.retry_status_fingerprint.as_deref() == Some(fingerprint.as_str()) { return; } self.retry_status_fingerprint = Some(fingerprint); self.add_normalized_entry(system_message(format!( "OpenCode retry (attempt {attempt}): {message} (next in {next}ms)" ))); } SessionStatus::Idle | SessionStatus::Busy | SessionStatus::Other => {} } } fn handle_todo_updated(&mut self, todos: &[SdkTodo], msg_store: &Arc) { let fingerprint = fingerprint_todos(todos); if self.todo_update_fingerprint.as_deref() == Some(fingerprint.as_str()) { return; } self.todo_update_fingerprint = Some(fingerprint); let mapped = todos .iter() .map(|todo| TodoItem { content: todo.content.clone(), status: todo.status.clone(), priority: Some(todo.priority.clone()), }) .collect::>(); let entry = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: "todo".to_string(), action_type: ActionType::TodoManagement { todos: mapped, operation: "update".to_string(), }, status: ToolStatus::Success, }, content: "TODO list updated".to_string(), metadata: None, }; if let Some(index) = self.todo_update_entry { replace_normalized_entry(msg_store, index, entry); } else { let index = add_normalized_entry(msg_store, &self.entry_index, entry); self.todo_update_entry = Some(index); } } fn maybe_emit_model_system_message(&mut self, info: &MessageInfo) { if self.model_system_message_emitted { return; } let Some(model_id) = info.model_id() else { return; }; let Some(provider_id) = info.provider_id() else { return; }; self.add_normalized_entry(system_message(format!( "model: {model_id} provider: {provider_id}" ))); self.model_system_message_emitted = true; } fn handle_part_update( &mut self, part: Part, delta: Option<&str>, worktree_path: &Path, msg_store: &Arc, ) { match part { Part::Text(part) => { let stream_key = part.id.clone().unwrap_or_else(|| part.message_id.clone()); self.part_kinds.insert( stream_key.clone(), (part.message_id.clone(), PartKind::AssistantText), ); if self.message_roles.get(&part.message_id) == Some(&MessageRole::User) { return; } let buffered = self.pending_deltas.remove(&stream_key); let (text, mode) = if let Some(delta) = delta { (delta, UpdateMode::Append) } else { (part.text.as_str(), UpdateMode::Set) }; let entry_index = self.entry_index.clone(); if let Some(pending) = buffered { let combined: String = pending.into_iter().collect(); update_streaming_text( &entry_index, &combined, NormalizedEntryType::AssistantMessage, &stream_key, &mut self.assistant_text, msg_store, UpdateMode::Set, ); } update_streaming_text( &entry_index, text, NormalizedEntryType::AssistantMessage, &stream_key, &mut self.assistant_text, msg_store, mode, ); } Part::Reasoning(part) => { let stream_key = part.id.clone().unwrap_or_else(|| part.message_id.clone()); self.part_kinds.insert( stream_key.clone(), (part.message_id.clone(), PartKind::Thinking), ); let buffered = self.pending_deltas.remove(&stream_key); let (text, mode) = if let Some(delta) = delta { (delta, UpdateMode::Append) } else { (part.text.as_str(), UpdateMode::Set) }; let entry_index = self.entry_index.clone(); if let Some(pending) = buffered { let combined: String = pending.into_iter().collect(); update_streaming_text( &entry_index, &combined, NormalizedEntryType::Thinking, &stream_key, &mut self.thinking_text, msg_store, UpdateMode::Set, ); } update_streaming_text( &entry_index, text, NormalizedEntryType::Thinking, &stream_key, &mut self.thinking_text, msg_store, mode, ); } Part::Tool(part) => { let part = *part; if part.call_id.trim().is_empty() { tracing::debug!( "Skipping tool part with empty call_id for message_id {}", part.message_id ); } let tool_state = self .tool_states .entry(part.call_id.clone()) .or_insert_with(|| ToolCallState::new(part.call_id.clone())); tool_state.set_approval_if_missing(self.approvals.get(&part.call_id).cloned()); tool_state.update_from_part(part); let entry = tool_state.to_normalized_entry(worktree_path); if let Some(index) = tool_state.index { replace_normalized_entry(msg_store, index, entry); } else { let index = add_normalized_entry(msg_store, &self.entry_index, entry); tool_state.index = Some(index); } } Part::Other => {} } } fn handle_part_delta(&mut self, event: MessagePartDeltaEvent, msg_store: &Arc) { if event.field != "text" || event.delta.is_empty() { return; } let Some((message_id, kind)) = self.part_kinds.get(&event.part_id) else { self.pending_deltas .entry(event.part_id) .or_default() .push(event.delta); return; }; let message_id = message_id.clone(); let kind = *kind; let entry_index = self.entry_index.clone(); match kind { PartKind::AssistantText => { if self.message_roles.get(&message_id) == Some(&MessageRole::User) { return; } update_streaming_text( &entry_index, &event.delta, NormalizedEntryType::AssistantMessage, &event.part_id, &mut self.assistant_text, msg_store, UpdateMode::Append, ); } PartKind::Thinking => { update_streaming_text( &entry_index, &event.delta, NormalizedEntryType::Thinking, &event.part_id, &mut self.thinking_text, msg_store, UpdateMode::Append, ); } } } fn handle_approval_requested( &mut self, tool_call_id: &str, approval_id: String, worktree_path: &Path, msg_store: &Arc, ) { let Some(tool_state) = self.tool_states.get_mut(tool_call_id) else { return; }; tool_state.approval = Some(ApprovalStatus::Pending); tool_state.approval_id = Some(approval_id); let Some(index) = tool_state.index else { return; }; replace_normalized_entry( msg_store, index, tool_state.to_normalized_entry(worktree_path), ); } fn handle_approval_response( &mut self, tool_call_id: &str, status: ApprovalStatus, worktree_path: &Path, msg_store: &Arc, ) { self.approvals .insert(tool_call_id.to_string(), status.clone()); if let ApprovalStatus::Denied { reason } = &status { let tool_name = self .tool_states .get(tool_call_id) .map(|t| t.tool_name().to_string()) .unwrap_or_else(|| "tool".to_string()); let idx = self.entry_index.next(); self.add_normalized_entry_with_index( idx, NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserFeedback { denied_tool: tool_name, }, content: reason .clone() .unwrap_or_else(|| "User denied this tool use request".to_string()) .trim() .to_string(), metadata: None, }, ); } let Some(tool_state) = self.tool_states.get_mut(tool_call_id) else { return; }; tool_state.set_approval(status); let Some(index) = tool_state.index else { return; }; replace_normalized_entry( msg_store, index, tool_state.to_normalized_entry(worktree_path), ); } fn handle_question_response( &mut self, tool_call_id: &str, status: QuestionStatus, worktree_path: &Path, msg_store: &Arc, ) { if let Some(tool_state) = self.tool_states.get_mut(tool_call_id) { tool_state.set_question_status(status.clone()); if let Some(index) = tool_state.index { replace_normalized_entry( msg_store, index, tool_state.to_normalized_entry(worktree_path), ); } } if let QuestionStatus::Answered { answers } = &status { let qa_pairs: Vec = answers .iter() .map(|qa| AnsweredQuestion { question: qa.question.clone(), answer: qa.answer.clone(), }) .collect(); self.add_normalized_entry(NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::UserAnsweredQuestions { answers: qa_pairs }, content: format!( "Answered {} question{}", answers.len(), if answers.len() != 1 { "s" } else { "" } ), metadata: None, }); } } fn handle_permission_asked( &mut self, event: PermissionAskedEvent, worktree_path: &Path, msg_store: &Arc, ) { let Some(tool) = event.tool else { self.add_normalized_entry(system_message(format!( "OpenCode permission requested: {}", event.permission ))); return; }; let call_id = tool.call_id.trim(); if call_id.is_empty() { return; } let tool_state = self .tool_states .entry(call_id.to_string()) .or_insert_with(|| ToolCallState::new(call_id.to_string())); // `permission` is an approval category (e.g. "edit", "bash"), not necessarily the tool // name ("write" vs "edit"). Only fall back to it when we haven't seen a tool name yet. if tool_state.tool_name() == "tool" { tool_state.set_tool_name(event.permission.clone()); } // `permission.asked` can carry richer metadata than the initial tool part updates (e.g. // diffs for file edits). Store that data so users can review it before approving. if tool_state.other_metadata().is_none() && !event.metadata.is_null() { tool_state.set_other_metadata(event.metadata.clone()); } if tool_state.file_edit_file_path().is_none() { if let Some(path) = extract_file_path_from_permission_metadata(&event.metadata) { tool_state.set_file_edit_file_path(path.to_string()); } else if let Some(pattern) = event .patterns .iter() .find(|p| !p.trim().is_empty() && !p.contains('*') && !p.contains('?')) { tool_state.set_file_edit_file_path(pattern.trim().to_string()); } } if let Some(diff) = extract_diff_from_metadata(&event.metadata) && !diff.trim().is_empty() { let should_update = match tool_state.file_edit_unified_diff() { None => true, Some(existing) => diff.len() > existing.len(), }; if should_update { tool_state.set_file_edit_unified_diff(diff.to_string()); } } let entry = tool_state.to_normalized_entry(worktree_path); if let Some(index) = tool_state.index { replace_normalized_entry(msg_store, index, entry); } else { let index = add_normalized_entry(msg_store, &self.entry_index, entry); tool_state.index = Some(index); } } fn handle_question_asked( &mut self, event: super::types::QuestionAskedEvent, worktree_path: &Path, msg_store: &Arc, ) { let call_id = event .tool .as_ref() .map(|tool| tool.call_id.trim()) .filter(|id| !id.is_empty()) .unwrap_or_else(|| event.id.trim()); if call_id.is_empty() { return; } let questions = parse_question_items_from_info(&event.questions); let tool_input = serde_json::json!({ "questions": event.questions }); let tool_state = self .tool_states .entry(call_id.to_string()) .or_insert_with(|| ToolCallState::new(call_id.to_string())); tool_state.set_tool_name("question".to_string()); tool_state.state = ToolStateStatus::Pending; tool_state.data = ToolData::Question { questions }; tool_state.apply_tool_data(Some(tool_input), None, None, None); tool_state.set_approval(ApprovalStatus::Pending); let entry = tool_state.to_normalized_entry(worktree_path); if let Some(index) = tool_state.index { replace_normalized_entry(msg_store, index, entry); } else { let index = add_normalized_entry(msg_store, &self.entry_index, entry); tool_state.index = Some(index); } } fn add_normalized_entry(&mut self, entry: NormalizedEntry) -> usize { add_normalized_entry(&self.msg_store, &self.entry_index, entry) } fn add_normalized_entry_with_index(&mut self, index: usize, entry: NormalizedEntry) { self.msg_store .push_patch(crate::logs::utils::ConversationPatch::add_normalized_entry( index, entry, )); } } fn update_streaming_text( entry_index: &EntryIndexProvider, text: &str, entry_type: NormalizedEntryType, stream_key: &str, map: &mut HashMap, msg_store: &Arc, mode: UpdateMode, ) { if text.is_empty() { return; } let is_new = !map.contains_key(stream_key); if is_new && text == "\n" { return; } let state = map .entry(stream_key.to_string()) .or_insert_with(|| StreamingText { index: entry_index.next(), content: String::new(), }); match mode { UpdateMode::Append => state.content.push_str(text), UpdateMode::Set => state.content = text.to_string(), } let entry = NormalizedEntry { timestamp: None, entry_type, content: state.content.clone(), metadata: None, }; upsert_normalized_entry(msg_store, state.index, entry, is_new); } #[derive(Debug, Clone)] struct ToolCallState { index: Option, call_id: String, tool_name: String, state: ToolStateStatus, title: Option, approval: Option, question: Option, approval_id: Option, data: ToolData, } #[derive(Debug, Clone, Default)] enum ToolData { #[default] Unknown, Bash { command: Option, output: Option, error: Option, exit_code: Option, }, Read { file_path: Option, }, FileEdit { kind: FileEditKind, file_path: Option, write_content: Option, unified_diff: Option, }, WebFetch { url: Option, }, Search { query: Option, }, Todo { operation: TodoOperation, todos: Vec, }, Task { description: Option, subagent_type: Option, output: Option, }, Question { questions: Vec, }, Other { input: Option, metadata: Option, output: Option, error: Option, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum FileEditKind { #[default] Edit, Write, MultiEdit, } #[derive(Debug, Clone, Copy, Default)] enum TodoOperation { Read, #[default] Write, } impl ToolCallState { fn new(call_id: String) -> Self { Self { index: None, call_id, tool_name: "tool".to_string(), state: ToolStateStatus::Unknown, title: None, approval: None, question: None, approval_id: None, data: ToolData::Other { input: None, metadata: None, output: None, error: None, }, } } fn tool_name(&self) -> &str { &self.tool_name } fn set_tool_name(&mut self, name: String) { if name.trim().is_empty() { return; } self.tool_name = name; self.maybe_promote(); } fn set_approval_if_missing(&mut self, approval: Option) { if self.approval.is_none() { self.approval = approval; } } fn set_approval(&mut self, approval: ApprovalStatus) { self.approval = Some(approval); } fn set_question_status(&mut self, question: QuestionStatus) { self.question = Some(question); } fn tool_status(&self) -> ToolStatus { if let Some(status) = self.question.as_ref().map(ToolStatus::from_question_status) { return status; } if let Some(ApprovalStatus::Denied { reason }) = &self.approval { return ToolStatus::Denied { reason: reason.clone(), }; } if matches!(self.approval, Some(ApprovalStatus::TimedOut)) { return ToolStatus::TimedOut; } if matches!(self.approval, Some(ApprovalStatus::Pending)) && let Some(ref id) = self.approval_id { return ToolStatus::PendingApproval { approval_id: id.clone(), }; } match self.state { ToolStateStatus::Completed => ToolStatus::Success, ToolStateStatus::Error => ToolStatus::Failed, _ => ToolStatus::Created, } } fn update_from_part(&mut self, part: ToolPart) { self.set_tool_name(part.tool.clone()); let (input, output, metadata, error) = match &part.state { ToolStateUpdate::Pending { input } => { self.state = ToolStateStatus::Pending; (input.clone(), None, None, None) } ToolStateUpdate::Running { input, title, metadata, } => { self.state = ToolStateStatus::Running; if let Some(t) = title.as_ref().filter(|t| !t.trim().is_empty()) { self.title = Some(t.clone()); } (input.clone(), None, metadata.clone(), None) } ToolStateUpdate::Completed { input, output, title, metadata, } => { self.state = ToolStateStatus::Completed; if let Some(t) = title.as_ref().filter(|t| !t.trim().is_empty()) { self.title = Some(t.clone()); } (input.clone(), output.clone(), metadata.clone(), None) } ToolStateUpdate::Error { input, error, metadata, } => { self.state = ToolStateStatus::Error; let err = error.clone().filter(|e| !e.trim().is_empty()); (input.clone(), None, metadata.clone(), err) } ToolStateUpdate::Unknown => (None, None, None, None), }; self.apply_tool_data(input, output, metadata, error); } fn apply_tool_data( &mut self, input: Option, output: Option, metadata: Option, error: Option, ) { match &mut self.data { ToolData::Bash { command, output: out, error: err, exit_code, } => { if let Some(v) = input.and_then(|v| serde_json::from_value::(v).ok()) { *command = Some(v.command); } if let Some(o) = output { *out = Some(o); *err = None; } if let Some(e) = error { *err = Some(e); } if let Some(m) = metadata { *exit_code = m.get("exit").and_then(Value::as_i64).map(|c| c as i32); } } ToolData::Read { file_path } => { if let Some(v) = input.and_then(|v| serde_json::from_value::(v).ok()) { *file_path = Some(v.file_path); } } ToolData::FileEdit { kind, file_path, write_content, unified_diff, } => { if let Some(inp) = input { match kind { FileEditKind::Write => { if let Ok(v) = serde_json::from_value::(inp) { *file_path = Some(v.file_path); *write_content = Some(v.content); } } FileEditKind::Edit | FileEditKind::MultiEdit => { if let Ok(v) = serde_json::from_value::(inp) { *file_path = Some(v.file_path); } } } } if matches!(kind, FileEditKind::Edit | FileEditKind::MultiEdit) && let Some(m) = metadata && let Some(d) = extract_diff_from_metadata(&m) { *unified_diff = Some(d.to_string()); } } ToolData::WebFetch { url } => { if let Some(u) = input.and_then(|v| v.get("url").and_then(Value::as_str).map(str::to_string)) { *url = Some(u); } } ToolData::Search { query } => { if let Some(inp) = input { *query = inp .get("query") .and_then(Value::as_str) .or_else(|| inp.get("pattern").and_then(Value::as_str)) .map(str::to_string); } } ToolData::Todo { operation, todos } => { let source = match operation { TodoOperation::Write => input, TodoOperation::Read => metadata, }; if let Some(v) = source.and_then(|v| serde_json::from_value::(v).ok()) { *todos = v.todos; } } ToolData::Task { description, subagent_type, output: task_output, } => { if let Some(inp) = input { if let Some(d) = inp .get("description") .and_then(Value::as_str) .map(str::to_string) { *description = Some(d); } if let Some(s) = inp .get("subagent_type") .and_then(Value::as_str) .map(str::to_string) { *subagent_type = Some(s); } } if let Some(o) = output { *task_output = Some(o); } } ToolData::Question { questions } => { if let Some(items) = input.and_then(|v| v.get("questions").and_then(Value::as_array).cloned()) { *questions = parse_question_items(&items); } } ToolData::Unknown => { // Upgrade Unknown to Other when we receive tool data self.data = ToolData::Other { input: None, metadata: None, output: None, error: None, }; self.apply_tool_data(input, output, metadata, error); } ToolData::Other { input: inp, metadata: meta, output: out, error: err, } => { if let Some(i) = input { *inp = Some(i); } if let Some(m) = metadata { *meta = Some(m); } if let Some(o) = output { *out = Some(o); *err = None; } if let Some(e) = error { *err = Some(e); } } } } /// Promote from generic/unknown to a specific tool type when tool name is recognized. fn maybe_promote(&mut self) { let (inp, meta, out, err) = match &self.data { ToolData::Other { input, metadata, output, error, } => ( input.clone(), metadata.clone(), output.clone(), error.clone(), ), ToolData::Unknown => (None, None, None, None), _ => return, // Already promoted }; self.data = match self.tool_name.as_str() { "bash" => ToolData::Bash { command: None, output: out.clone(), error: err.clone(), exit_code: None, }, "read" => ToolData::Read { file_path: None }, "edit" | "write" | "multiedit" => ToolData::FileEdit { kind: match self.tool_name.as_str() { "write" => FileEditKind::Write, "multiedit" => FileEditKind::MultiEdit, _ => FileEditKind::Edit, }, file_path: None, write_content: None, unified_diff: None, }, "webfetch" => ToolData::WebFetch { url: None }, "websearch" | "codesearch" | "grep" | "glob" => ToolData::Search { query: None }, "todoread" | "todowrite" => ToolData::Todo { operation: if self.tool_name == "todoread" { TodoOperation::Read } else { TodoOperation::Write }, todos: vec![], }, "task" => ToolData::Task { description: None, subagent_type: None, output: None, }, "question" => ToolData::Question { questions: vec![] }, _ => return, }; // Re-apply the data we had (only needed for non-Bash tools as Bash already has out/err) self.apply_tool_data(inp, out, meta, err); } fn to_normalized_entry(&self, worktree_path: &Path) -> NormalizedEntry { let action_type = self.build_action_type(worktree_path); let content = self.build_content(&action_type); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: self.tool_name.clone(), action_type, status: self.tool_status(), }, content, metadata: serde_json::to_value(ToolCallMetadata { tool_call_id: self.call_id.clone(), }) .ok(), } } fn build_action_type(&self, worktree_path: &Path) -> ActionType { match &self.data { ToolData::Bash { command, output, error, exit_code, } => { let cmd = command.clone().unwrap_or_default(); ActionType::CommandRun { command: cmd.clone(), result: Some(CommandRunResult { exit_status: exit_code.map(|code| CommandExitStatus::ExitCode { code }), output: output.as_deref().or(error.as_deref()).map(str::to_string), }), category: CommandCategory::from_command(&cmd), } } ToolData::Read { file_path } => ActionType::FileRead { path: file_path .as_deref() .map(|p| make_relative_path(p, worktree_path)) .unwrap_or_default(), }, ToolData::FileEdit { kind, file_path, write_content, unified_diff, } => { let path = file_path .as_deref() .map(|p| make_relative_path(p, worktree_path)) .unwrap_or_default(); let changes = match kind { FileEditKind::Write => write_content .as_ref() .filter(|s| !s.is_empty()) .map(|c| vec![FileChange::Write { content: c.clone() }]) .unwrap_or_default(), FileEditKind::Edit | FileEditKind::MultiEdit => unified_diff .as_ref() .map(|d| { vec![FileChange::Edit { unified_diff: workspace_utils::diff::normalize_unified_diff( &path, d, ), has_line_numbers: true, }] }) .unwrap_or_default(), }; ActionType::FileEdit { path, changes } } ToolData::WebFetch { url } => ActionType::WebFetch { url: url.clone().unwrap_or_default(), }, ToolData::Search { query } => ActionType::Search { query: query.clone().unwrap_or_default(), }, ToolData::Todo { operation, todos } => ActionType::TodoManagement { todos: todos.clone(), operation: match operation { TodoOperation::Read => "read", TodoOperation::Write => "write", } .to_string(), }, ToolData::Task { description, subagent_type, output, } => ActionType::TaskCreate { description: description.clone().unwrap_or_default(), subagent_type: subagent_type.clone(), result: output .as_deref() .map(|o| ToolResult::markdown(o.to_string())), }, ToolData::Question { questions } => ActionType::AskUserQuestion { questions: questions.clone(), }, ToolData::Unknown => ActionType::Tool { tool_name: self.tool_name.clone(), arguments: None, result: None, }, ToolData::Other { input, output, error, .. } => ActionType::Tool { tool_name: self.tool_name.clone(), arguments: input .as_ref() .and_then(|v| v.as_object().map(|_| v.clone())), result: output .as_deref() .or(error.as_deref()) .map(|o| ToolResult::markdown(o.to_string())), }, } } fn build_content(&self, action_type: &ActionType) -> String { let content = match action_type { ActionType::CommandRun { command, .. } => command.clone(), ActionType::FileRead { path } => path.clone(), ActionType::FileEdit { path, .. } => path.clone(), ActionType::Search { query } => query.clone(), ActionType::WebFetch { url } => url.clone(), ActionType::TodoManagement { .. } => "TODO list updated".to_string(), ActionType::TaskCreate { description, .. } => { if description.is_empty() { "Task".to_string() } else { format!("Task: `{description}`") } } ActionType::AskUserQuestion { questions } => { if questions.len() == 1 { questions[0].question.clone() } else { format!("{} questions", questions.len()) } } _ => String::new(), } .trim() .to_string(); if !content.is_empty() { content } else { self.title.as_deref().unwrap_or(&self.tool_name).to_string() } } /// Access to metadata for permission.asked handling fn other_metadata(&self) -> Option<&Value> { match &self.data { ToolData::Other { metadata, .. } => metadata.as_ref(), _ => None, } } fn set_other_metadata(&mut self, meta: Value) { if let ToolData::Other { metadata, .. } = &mut self.data { *metadata = Some(meta); } } fn file_edit_file_path(&self) -> Option<&str> { match &self.data { ToolData::FileEdit { file_path, .. } => file_path.as_deref(), _ => None, } } fn set_file_edit_file_path(&mut self, path: String) { if let ToolData::FileEdit { file_path, .. } = &mut self.data { *file_path = Some(path); } } fn file_edit_unified_diff(&self) -> Option<&str> { match &self.data { ToolData::FileEdit { unified_diff, .. } => unified_diff.as_deref(), _ => None, } } fn set_file_edit_unified_diff(&mut self, diff: String) { if let ToolData::FileEdit { unified_diff, .. } = &mut self.data { *unified_diff = Some(diff); } } } #[derive(Debug, Deserialize)] struct BashInput { command: String, } #[derive(Debug, Deserialize)] struct FilePathInput { #[serde(rename = "filePath")] file_path: String, } #[derive(Debug, Deserialize)] struct WriteInput { #[serde(rename = "filePath")] file_path: String, content: String, } #[derive(Debug, Deserialize)] struct TodosContainer { #[serde(default)] todos: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ToolStateStatus { Pending, Running, Completed, Error, Unknown, } fn make_relative_path(path: &str, worktree_path: &Path) -> String { make_path_relative(path, &worktree_path.to_string_lossy()) } fn fingerprint_todos(todos: &[SdkTodo]) -> String { let mut parts = todos .iter() .map(|t| { format!( "{}:{}:{}:{}", t.id.as_deref().unwrap_or(""), t.status, t.priority, t.content ) }) .collect::>(); parts.sort(); parts.join("|") } fn extract_diff_from_metadata(metadata: &Value) -> Option<&str> { metadata.get("diff").and_then(Value::as_str).or_else(|| { metadata .get("results") .and_then(Value::as_array) .and_then(|results| results.last()) .and_then(|last| last.get("diff")) .and_then(Value::as_str) }) } fn extract_file_path_from_permission_metadata(metadata: &Value) -> Option<&str> { let candidate = metadata .get("filePath") .and_then(Value::as_str) .or_else(|| metadata.get("filepath").and_then(Value::as_str)) .or_else(|| metadata.get("path").and_then(Value::as_str)) .or_else(|| metadata.get("file").and_then(Value::as_str))?; let trimmed = candidate.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } } fn parse_question_items_from_info(items: &[QuestionInfo]) -> Vec { items .iter() .map(|q| AskUserQuestionItem { question: q.question.clone(), header: q.header.clone(), options: q .options .iter() .map(|o| AskUserQuestionOption { label: o.label.clone(), description: o.description.clone(), }) .collect(), multi_select: q.multiple.unwrap_or(false), }) .collect() } fn parse_question_items(items: &[Value]) -> Vec { let infos: Vec = items .iter() .filter_map(|v| serde_json::from_value(v.clone()).ok()) .collect(); parse_question_items_from_info(&infos) } ================================================ FILE: crates/executors/src/executors/opencode/sdk.rs ================================================ use std::{ collections::{HashMap, HashSet}, future::Future, io, sync::Arc, time::Duration, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use eventsource_stream::Eventsource; use futures::StreamExt; use rand::{Rng, distributions::Alphanumeric}; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::{ io::{AsyncWrite, AsyncWriteExt, BufWriter}, sync::{Mutex as AsyncMutex, mpsc, oneshot}, }; use tokio_util::sync::CancellationToken; use workspace_utils::approvals::{ApprovalStatus, QuestionAnswer, QuestionStatus}; use super::{ slash_commands, types::{OpencodeExecutorEvent, ProviderInfo, ProviderListResponse}, }; use crate::{ approvals::{ExecutorApprovalError, ExecutorApprovalService}, env::RepoContext, executors::{ExecutorError, opencode::models::maybe_emit_token_usage}, }; #[derive(Clone)] pub struct LogWriter { writer: Arc>>>, } impl LogWriter { pub fn new(writer: impl AsyncWrite + Send + Unpin + 'static) -> Self { Self { writer: Arc::new(AsyncMutex::new(BufWriter::new(Box::new(writer)))), } } pub async fn log_event(&self, event: &OpencodeExecutorEvent) -> Result<(), ExecutorError> { let raw = serde_json::to_string(event).map_err(|err| ExecutorError::Io(io::Error::other(err)))?; self.log_raw(&raw).await } pub async fn log_error(&self, message: String) -> Result<(), ExecutorError> { self.log_event(&OpencodeExecutorEvent::Error { message }) .await } pub async fn log_slash_command_result(&self, message: String) -> Result<(), ExecutorError> { self.log_event(&OpencodeExecutorEvent::SlashCommandResult { message }) .await } async fn log_raw(&self, raw: &str) -> Result<(), ExecutorError> { let mut guard = self.writer.lock().await; guard .write_all(raw.as_bytes()) .await .map_err(ExecutorError::Io)?; guard.write_all(b"\n").await.map_err(ExecutorError::Io)?; guard.flush().await.map_err(ExecutorError::Io)?; Ok(()) } } #[derive(Clone)] pub struct RunConfig { pub base_url: String, pub directory: String, pub prompt: String, pub resume_session_id: Option, pub model: Option, pub model_variant: Option, pub agent: Option, pub approvals: Option>, pub auto_approve: bool, pub server_password: String, /// Cache key for model context windows. Should be derived from configuration /// that affects available models (e.g., env vars, base command). pub models_cache_key: String, pub commit_reminder: bool, pub commit_reminder_prompt: String, pub repo_context: RepoContext, } /// Generate a cryptographically secure random password for OpenCode server auth. pub fn generate_server_password() -> String { rand::thread_rng() .sample_iter(&Alphanumeric) .take(32) .map(char::from) .collect() } #[derive(Debug, Deserialize)] struct HealthResponse { healthy: bool, version: String, } #[derive(Debug, Deserialize)] struct SessionResponse { id: String, } /// Information about a discovered command. #[derive(Debug, Deserialize, Clone)] pub struct CommandInfo { pub name: String, #[serde(default)] pub description: Option, } /// Information about an agent. #[derive(Debug, Deserialize, Clone)] pub struct AgentInfo { pub name: String, #[serde(default)] pub description: Option, } /// Configuration response from the server. #[derive(Debug, Deserialize)] pub struct ConfigResponse { #[serde(default)] pub model: Option, #[serde(default)] pub plugin: Vec, } /// Provider configuration response. #[derive(Debug, Deserialize)] pub struct ConfigProvidersResponse { pub providers: Vec, pub default: HashMap, } /// LSP server status. #[derive(Debug, Deserialize, Clone)] pub struct LspStatus { pub name: String, pub root: String, pub status: String, } /// Formatter status. #[derive(Debug, Deserialize, Clone)] pub struct FormatterStatus { pub name: String, pub extensions: Vec, pub enabled: bool, } #[derive(Debug, Serialize)] struct PromptRequest { #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] agent: Option, #[serde(skip_serializing_if = "Option::is_none")] variant: Option, parts: Vec, } #[derive(Debug, Serialize, Clone)] pub struct ModelSpec { #[serde(rename = "providerID")] pub provider_id: String, #[serde(rename = "modelID")] pub model_id: String, } #[derive(Debug, Serialize)] struct TextPartInput { r#type: &'static str, text: String, } #[derive(Debug, Clone)] pub enum ControlEvent { Idle, AuthRequired { message: String }, SessionError { message: String }, Disconnected, } #[derive(Clone)] pub(crate) struct PendingApprovals { inner: Arc>>>, } impl PendingApprovals { pub(crate) fn new() -> Self { Self { inner: Arc::new(AsyncMutex::new(Vec::new())), } } async fn push(&self) -> oneshot::Sender<()> { let (tx, rx) = oneshot::channel(); self.inner.lock().await.push(rx); tx } async fn wait(&self, cancel: CancellationToken) -> bool { let mut waited = false; loop { let receivers = { let mut guard = self.inner.lock().await; if guard.is_empty() { return waited; } waited = true; guard.drain(..).collect::>() }; for rx in receivers { tokio::select! { _ = cancel.cancelled() => return waited, _ = rx => {} } } } } } pub async fn run_session( config: RunConfig, log_writer: LogWriter, cancel: CancellationToken, ) -> Result<(), ExecutorError> { let client = build_opencode_client(&config.directory, &config.server_password)?; run_session_inner(config, log_writer, client, cancel).await } pub async fn run_slash_command( config: RunConfig, log_writer: LogWriter, command: slash_commands::OpencodeSlashCommand, cancel: CancellationToken, ) -> Result<(), ExecutorError> { let client = build_opencode_client(&config.directory, &config.server_password)?; slash_commands::execute(config, command, log_writer, client, cancel.clone()).await } async fn run_session_inner( config: RunConfig, log_writer: LogWriter, client: reqwest::Client, cancel: CancellationToken, ) -> Result<(), ExecutorError> { tokio::select! { _ = cancel.cancelled() => return Ok(()), res = wait_for_health(&client, &config.base_url) => res?, } let session_id = match config.resume_session_id.as_deref() { Some(existing) => { tokio::select! { _ = cancel.cancelled() => return Ok(()), res = fork_session(&client, &config.base_url, &config.directory, existing) => res?, } } None => tokio::select! { _ = cancel.cancelled() => return Ok(()), res = create_session(&client, &config.base_url, &config.directory) => res?, }, }; log_writer .log_event(&OpencodeExecutorEvent::SessionStart { session_id: session_id.clone(), }) .await?; let model = config.model.as_deref().and_then(parse_model); let (control_tx, mut control_rx) = mpsc::unbounded_channel::(); let pending_approvals = PendingApprovals::new(); let event_resp = tokio::select! { _ = cancel.cancelled() => return Ok(()), res = connect_event_stream(&client, &config.base_url, &config.directory, None) => res?, }; let event_handle = tokio::spawn(spawn_event_listener( EventListenerConfig { client: client.clone(), base_url: config.base_url.clone(), directory: config.directory.clone(), session_id: session_id.clone(), log_writer: log_writer.clone(), approvals: config.approvals.clone(), auto_approve: config.auto_approve, control_tx, pending_approvals: pending_approvals.clone(), models_cache_key: config.models_cache_key.clone(), cancel: cancel.clone(), }, event_resp, )); let prompt_fut = Box::pin(prompt( &client, &config.base_url, &config.directory, &session_id, &config.prompt, model.clone(), config.model_variant.clone(), config.agent.clone(), )); let prompt_result = run_request_with_control( prompt_fut, &mut control_rx, &pending_approvals, cancel.clone(), ) .await; if cancel.is_cancelled() { send_abort(&client, &config.base_url, &config.directory, &session_id).await; event_handle.abort(); return Ok(()); } if let Err(err) = prompt_result { let _ = pending_approvals.wait(cancel.clone()).await; event_handle.abort(); return Err(err); } // Handle commit reminder if enabled if config.commit_reminder && !cancel.is_cancelled() && let status = config.repo_context.check_uncommitted_changes().await && !status.is_empty() { let reminder_prompt = format!("{}\n{}", config.commit_reminder_prompt, status); tracing::debug!("Sending commit reminder prompt to OpenCode session"); // Log as system message so it's visible in the UI (user_message gets filtered out) let _ = log_writer .log_event(&OpencodeExecutorEvent::SystemMessage { content: reminder_prompt.clone(), }) .await; let reminder_fut = Box::pin(prompt( &client, &config.base_url, &config.directory, &session_id, &reminder_prompt, model, config.model_variant.clone(), config.agent.clone(), )); let reminder_result = run_request_with_control( reminder_fut, &mut control_rx, &pending_approvals, cancel.clone(), ) .await; if let Err(e) = reminder_result { // Log but don't fail the session on commit reminder errors tracing::warn!("Commit reminder prompt failed: {e}"); } } let _ = pending_approvals.wait(cancel.clone()).await; if cancel.is_cancelled() { send_abort(&client, &config.base_url, &config.directory, &session_id).await; } event_handle.abort(); log_writer.log_event(&OpencodeExecutorEvent::Done).await?; Ok(()) } fn build_default_headers(directory: &str, password: &str) -> HeaderMap { let mut headers = HeaderMap::new(); if let Ok(value) = HeaderValue::from_str(directory) { headers.insert("x-opencode-directory", value); } let credentials = BASE64.encode(format!("opencode:{password}")); if let Ok(value) = HeaderValue::from_str(&format!("Basic {credentials}")) { headers.insert(AUTHORIZATION, value); } headers } /// Build HTTP client with OpenCode authentication headers. /// Uses Basic Auth: "opencode:{password}" base64 encoded. pub fn build_authenticated_client( directory: &str, password: &str, ) -> Result { build_opencode_client(directory, password) } fn build_opencode_client( directory: &str, password: &str, ) -> Result { const OPENCODE_HTTP_TIMEOUT: Duration = Duration::from_secs(30); const OPENCODE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); reqwest::Client::builder() .default_headers(build_default_headers(directory, password)) .connect_timeout(OPENCODE_CONNECT_TIMEOUT) .timeout(OPENCODE_HTTP_TIMEOUT) .build() .map_err(|err| ExecutorError::Io(io::Error::other(err))) } const OPENCODE_PROMPT_TIMEOUT: Duration = Duration::from_secs(60 * 30); fn append_session_error(session_error: &mut Option, message: String) { match session_error { Some(existing) => { existing.push('\n'); existing.push_str(&message); } None => *session_error = Some(message), } } pub async fn run_request_with_control( mut request_fut: F, control_rx: &mut mpsc::UnboundedReceiver, pending_approvals: &PendingApprovals, cancel: CancellationToken, ) -> Result<(), ExecutorError> where F: Future> + Unpin, { let mut idle_seen = false; let mut session_error: Option = None; let request_result = loop { tokio::select! { _ = cancel.cancelled() => return Ok(()), res = &mut request_fut => break res, event = control_rx.recv() => match event { Some(ControlEvent::AuthRequired { message }) => return Err(ExecutorError::AuthRequired(message)), Some(ControlEvent::SessionError { message }) => append_session_error(&mut session_error, message), Some(ControlEvent::Disconnected) if !cancel.is_cancelled() => { return Err(ExecutorError::Io(io::Error::other("OpenCode event stream disconnected while request was running"))); } Some(ControlEvent::Disconnected) => return Ok(()), Some(ControlEvent::Idle) => idle_seen = true, None => {} } } }; if let Err(err) = request_result { if cancel.is_cancelled() { return Ok(()); } return Err(err); } if pending_approvals.wait(cancel.clone()).await { idle_seen = false; } if !idle_seen { // The OpenCode server streams events independently; wait for `session.idle` so we capture // tail updates reliably (e.g. final tool completion events). loop { tokio::select! { _ = cancel.cancelled() => return Ok(()), event = control_rx.recv() => match event { Some(ControlEvent::Idle) | None => break, Some(ControlEvent::AuthRequired { message }) => return Err(ExecutorError::AuthRequired(message)), Some(ControlEvent::SessionError { message }) => append_session_error(&mut session_error, message), Some(ControlEvent::Disconnected) if !cancel.is_cancelled() => { return Err(ExecutorError::Io(io::Error::other( "OpenCode event stream disconnected while waiting for session to go idle", ))); } Some(ControlEvent::Disconnected) => return Ok(()), } } } } if let Some(message) = session_error { if cancel.is_cancelled() { return Ok(()); } return Err(ExecutorError::Io(io::Error::other(message))); } Ok(()) } pub async fn wait_for_health( client: &reqwest::Client, base_url: &str, ) -> Result<(), ExecutorError> { let deadline = tokio::time::Instant::now() + Duration::from_secs(20); let mut last_err: Option = None; loop { if tokio::time::Instant::now() > deadline { return Err(ExecutorError::Io(io::Error::other(format!( "Timed out waiting for OpenCode server health: {}", last_err.unwrap_or_else(|| "unknown error".to_string()) )))); } let resp = client.get(format!("{base_url}/global/health")).send().await; match resp { Ok(resp) => { if !resp.status().is_success() { last_err = Some(format!("HTTP {}", resp.status())); } else if let Ok(body) = resp.json::().await { if body.healthy { return Ok(()); } last_err = Some(format!("unhealthy server (version {})", body.version)); } else { last_err = Some("failed to parse health response".to_string()); } } Err(err) => { last_err = Some(err.to_string()); } } tokio::time::sleep(Duration::from_millis(150)).await; } } pub async fn create_session( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result { let resp = client .post(format!("{base_url}/session")) .query(&[("directory", directory)]) .json(&serde_json::json!({})) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.create failed: HTTP {}", resp.status() )))); } let session = resp .json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; Ok(session.id) } pub async fn fork_session( client: &reqwest::Client, base_url: &str, directory: &str, session_id: &str, ) -> Result { let resp = client .post(format!("{base_url}/session/{session_id}/fork")) .query(&[("directory", directory)]) .json(&serde_json::json!({})) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.fork failed: HTTP {}", resp.status() )))); } let session = resp .json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; Ok(session.id) } #[allow(clippy::too_many_arguments)] async fn prompt( client: &reqwest::Client, base_url: &str, directory: &str, session_id: &str, prompt: &str, model: Option, model_variant: Option, agent: Option, ) -> Result<(), ExecutorError> { let req = PromptRequest { model, agent, variant: model_variant, parts: vec![TextPartInput { r#type: "text", text: prompt.to_string(), }], }; let resp = client .post(format!("{base_url}/session/{session_id}/message")) .query(&[("directory", directory)]) .timeout(OPENCODE_PROMPT_TIMEOUT) .json(&req) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; let status = resp.status(); let body = resp .text() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; // The OpenCode server uses streaming responses and may set the HTTP status early; validate // success using the response body shape as well. if !status.is_success() { return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.prompt failed: HTTP {status} {body}" )))); } let trimmed = body.trim(); if trimmed.is_empty() { return Err(ExecutorError::Io(io::Error::other( "OpenCode session.prompt returned empty response body", ))); } let parsed: Value = serde_json::from_str(trimmed).map_err(|err| ExecutorError::Io(io::Error::other(err)))?; // Success response: { info, parts } if parsed.get("info").is_some() && parsed.get("parts").is_some() { return Ok(()); } // Error response: { name, data } if let Some(name) = parsed.get("name").and_then(Value::as_str) { let message = parsed .pointer("/data/message") .and_then(Value::as_str) .unwrap_or(trimmed); return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.prompt failed: {name}: {message}" )))); } Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.prompt returned unexpected response: {trimmed}" )))) } #[derive(Debug, Serialize)] struct SessionCommandRequest { command: String, arguments: String, #[serde(skip_serializing_if = "Option::is_none")] agent: Option, #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] variant: Option, } #[allow(clippy::too_many_arguments)] pub async fn session_command( client: &reqwest::Client, base_url: &str, directory: &str, session_id: &str, command: String, arguments: String, agent: Option, model: Option, model_variant: Option, ) -> Result<(), ExecutorError> { let req = SessionCommandRequest { command, arguments, agent, model, variant: model_variant, }; let resp = client .post(format!("{base_url}/session/{session_id}/command")) .query(&[("directory", directory)]) .json(&req) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; let status = resp.status(); let body = resp .text() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !status.is_success() { return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.command failed: HTTP {status} {body}" )))); } let trimmed = body.trim(); if trimmed.is_empty() { return Err(ExecutorError::Io(io::Error::other( "OpenCode session.command returned empty response body", ))); } let parsed: Value = serde_json::from_str(trimmed).map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if parsed.get("info").is_some() && parsed.get("parts").is_some() { return Ok(()); } if let Some(name) = parsed.get("name").and_then(Value::as_str) { let message = parsed .pointer("/data/message") .and_then(Value::as_str) .unwrap_or(trimmed); return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.command failed: {name}: {message}" )))); } Err(ExecutorError::Io(io::Error::other(format!( "OpenCode session.command returned unexpected response: {trimmed}" )))) } #[derive(Debug, Serialize)] struct SummarizeRequest { #[serde(rename = "providerID")] provider_id: String, #[serde(rename = "modelID")] model_id: String, auto: bool, } pub async fn session_summarize( client: &reqwest::Client, base_url: &str, directory: &str, session_id: &str, model: ModelSpec, ) -> Result<(), ExecutorError> { let req = SummarizeRequest { provider_id: model.provider_id, model_id: model.model_id, auto: false, }; let resp = client .post(format!("{base_url}/session/{session_id}/summarize")) .query(&[("directory", directory)]) .json(&req) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "session.summarize").await); } let _ = resp .json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; Ok(()) } pub async fn list_commands( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result, ExecutorError> { let resp = client .get(format!("{base_url}/command")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "command.list").await); } resp.json::>() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn list_agents( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result, ExecutorError> { let resp = client .get(format!("{base_url}/agent")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "agent.list").await); } resp.json::>() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn config_get( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result { let resp = client .get(format!("{base_url}/config")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "config.get").await); } resp.json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn list_config_providers( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result { let resp = client .get(format!("{base_url}/config/providers")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "config.providers").await); } resp.json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn list_providers( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result { let resp = client .get(format!("{base_url}/provider")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "provider.list").await); } resp.json::() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn mcp_status( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result, ExecutorError> { let resp = client .get(format!("{base_url}/mcp")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "mcp.status").await); } resp.json::>() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn lsp_status( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result, ExecutorError> { let resp = client .get(format!("{base_url}/lsp")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "lsp.status").await); } resp.json::>() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } pub async fn formatter_status( client: &reqwest::Client, base_url: &str, directory: &str, ) -> Result, ExecutorError> { let resp = client .get(format!("{base_url}/formatter")) .query(&[("directory", directory)]) .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { return Err(build_response_error(resp, "formatter.status").await); } resp.json::>() .await .map_err(|err| ExecutorError::Io(io::Error::other(err))) } async fn build_response_error(resp: reqwest::Response, context: &str) -> ExecutorError { let status = resp.status(); let body = resp .text() .await .unwrap_or_else(|_| "".to_string()); ExecutorError::Io(io::Error::other(format!( "OpenCode {context} failed: HTTP {status} {body}" ))) } pub async fn send_abort( client: &reqwest::Client, base_url: &str, directory: &str, session_id: &str, ) { let request = client .post(format!("{base_url}/session/{session_id}/abort")) .query(&[("directory", directory)]); let _ = tokio::time::timeout(Duration::from_millis(800), async move { let resp = request.send().await; if let Ok(resp) = resp { // Drain body let _ = resp.bytes().await; } }) .await; } fn parse_model(model: &str) -> Option { let (provider_id, model_id) = match model.split_once('/') { Some((provider, rest)) => (provider.to_string(), rest.to_string()), None => (model.to_string(), String::new()), }; Some(ModelSpec { provider_id, model_id, }) } fn parse_model_strict(model: &str) -> Option { let (provider_id, model_id) = model.split_once('/')?; let model_id = model_id.trim(); if model_id.is_empty() { return None; } Some(ModelSpec { provider_id: provider_id.to_string(), model_id: model_id.to_string(), }) } pub async fn resolve_compaction_model( client: &reqwest::Client, base_url: &str, directory: &str, configured_model: Option<&str>, ) -> Result { if let Some(model) = configured_model.and_then(parse_model_strict) { return Ok(model); } let config = config_get(client, base_url, directory).await?; if let Some(model) = config.model.as_deref().and_then(parse_model_strict) { return Ok(model); } let providers = list_config_providers(client, base_url, directory).await?; let mut provider_ids: Vec<_> = providers.default.keys().cloned().collect(); provider_ids.sort(); if let Some(provider_id) = provider_ids.first() && let Some(model_id) = providers.default.get(provider_id) { return Ok(ModelSpec { provider_id: provider_id.clone(), model_id: model_id.clone(), }); } if let Some(provider) = providers.providers.first() && let Some((model_id, _)) = provider.models.iter().next() { return Ok(ModelSpec { provider_id: provider.id.clone(), model_id: model_id.clone(), }); } Err(ExecutorError::Io(io::Error::other( "OpenCode compaction requires a configured model", ))) } pub async fn connect_event_stream( client: &reqwest::Client, base_url: &str, directory: &str, last_event_id: Option<&str>, ) -> Result { let mut req = client .get(format!("{base_url}/event")) .header(reqwest::header::ACCEPT, "text/event-stream") .query(&[("directory", directory)]); if let Some(last_event_id) = last_event_id { req = req.header("Last-Event-ID", last_event_id); } let resp = req .send() .await .map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !resp.status().is_success() { let status = resp.status(); let body = resp .text() .await .unwrap_or_else(|_| "".to_string()); return Err(ExecutorError::Io(io::Error::other(format!( "OpenCode event stream failed: HTTP {status} {body}" )))); } Ok(resp) } pub struct EventListenerConfig { pub client: reqwest::Client, pub base_url: String, pub directory: String, pub session_id: String, pub log_writer: LogWriter, pub approvals: Option>, pub auto_approve: bool, pub control_tx: mpsc::UnboundedSender, pub pending_approvals: PendingApprovals, pub models_cache_key: String, pub cancel: CancellationToken, } pub async fn spawn_event_listener(config: EventListenerConfig, initial_resp: reqwest::Response) { let EventListenerConfig { client, base_url, directory, session_id, log_writer, approvals, auto_approve, control_tx, pending_approvals, models_cache_key, cancel, } = config; let mut seen_permissions: HashSet = HashSet::new(); let mut last_event_id: Option = None; let mut base_retry_delay = Duration::from_millis(3000); let mut attempt: u32 = 0; let max_attempts: u32 = 20; let mut resp: Option = Some(initial_resp); loop { let current_resp = match resp.take() { Some(r) => { attempt = 0; r } None => { match connect_event_stream(&client, &base_url, &directory, last_event_id.as_deref()) .await { Ok(r) => { attempt = 0; r } Err(err) => { let _ = log_writer .log_error(format!("OpenCode event stream reconnect failed: {err}")) .await; attempt += 1; if attempt >= max_attempts { let _ = control_tx.send(ControlEvent::Disconnected); return; } tokio::time::sleep(exponential_backoff(base_retry_delay, attempt)).await; continue; } } } }; let outcome = process_event_stream( EventStreamContext { seen_permissions: &mut seen_permissions, client: &client, base_url: &base_url, directory: &directory, session_id: &session_id, log_writer: &log_writer, approvals: approvals.clone(), auto_approve, control_tx: &control_tx, pending_approvals: &pending_approvals, base_retry_delay: &mut base_retry_delay, last_event_id: &mut last_event_id, models_cache_key: &models_cache_key, cancel: cancel.clone(), }, current_resp, ) .await; match outcome { Ok(EventStreamOutcome::Idle) => { // Keep listening - there may be more prompts (e.g., commit reminder) // The task will be aborted by event_handle.abort() when done resp = None; continue; } Ok(EventStreamOutcome::Terminal) => return, Ok(EventStreamOutcome::Disconnected) | Err(_) => { attempt += 1; if attempt >= max_attempts { let _ = control_tx.send(ControlEvent::Disconnected); return; } } } tokio::time::sleep(exponential_backoff(base_retry_delay, attempt)).await; resp = None; } } fn exponential_backoff(base: Duration, attempt: u32) -> Duration { let exp = attempt.saturating_sub(1).min(10); let mult = 1u32 << exp; base.checked_mul(mult) .unwrap_or(Duration::from_secs(30)) .min(Duration::from_secs(30)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EventStreamOutcome { Idle, Terminal, Disconnected, } pub(super) struct EventStreamContext<'a> { seen_permissions: &'a mut HashSet, pub client: &'a reqwest::Client, pub base_url: &'a str, pub directory: &'a str, pub session_id: &'a str, pub log_writer: &'a LogWriter, approvals: Option>, auto_approve: bool, control_tx: &'a mpsc::UnboundedSender, pending_approvals: &'a PendingApprovals, base_retry_delay: &'a mut Duration, last_event_id: &'a mut Option, /// Cache key for model context windows, derived from config that affects available models. pub models_cache_key: &'a str, cancel: CancellationToken, } async fn process_event_stream( ctx: EventStreamContext<'_>, resp: reqwest::Response, ) -> Result { let mut stream = resp.bytes_stream().eventsource(); loop { let evt = tokio::select! { _ = ctx.cancel.cancelled() => { return Ok(EventStreamOutcome::Terminal); } evt = stream.next() => { match evt { Some(evt) => evt, None => break, } } }; let evt = evt.map_err(|err| ExecutorError::Io(io::Error::other(err)))?; if !evt.id.trim().is_empty() { *ctx.last_event_id = Some(evt.id.trim().to_string()); } if let Some(retry) = evt.retry { *ctx.base_retry_delay = retry; } let trimmed = evt.data.trim(); if trimmed.is_empty() { continue; } let Ok(data) = serde_json::from_str::(trimmed) else { let _ = ctx .log_writer .log_error(format!( "OpenCode event stream delivered non-JSON event payload: {trimmed}" )) .await; continue; }; let Some(event_type) = data.get("type").and_then(Value::as_str) else { continue; }; if !event_matches_session(event_type, &data, ctx.session_id) { continue; } let _ = ctx .log_writer .log_event(&OpencodeExecutorEvent::SdkEvent { event: data.clone(), }) .await; match event_type { "message.updated" => { maybe_emit_token_usage(&ctx, &data).await; } "session.status" => { if let Some(status) = data .pointer("/properties/status/type") .and_then(Value::as_str) && status.eq_ignore_ascii_case("idle") { let _ = ctx.control_tx.send(ControlEvent::Idle); return Ok(EventStreamOutcome::Idle); } } "session.idle" => { let _ = ctx.control_tx.send(ControlEvent::Idle); return Ok(EventStreamOutcome::Idle); } "session.error" => { let error_type = data .pointer("/properties/error/name") .or_else(|| data.pointer("/properties/error/type")) .and_then(Value::as_str) .unwrap_or("unknown"); let message = data .pointer("/properties/error/data/message") .or_else(|| data.pointer("/properties/error/message")) .and_then(Value::as_str) .unwrap_or("OpenCode session error") .to_string(); if error_type == "ProviderAuthError" { let _ = ctx.control_tx.send(ControlEvent::AuthRequired { message }); return Ok(EventStreamOutcome::Terminal); } let _ = ctx.control_tx.send(ControlEvent::SessionError { message }); } "question.asked" => { let request_id = data .pointer("/properties/id") .and_then(Value::as_str) .unwrap_or_default() .to_string(); if request_id.is_empty() || !ctx.seen_permissions.insert(request_id.clone()) { continue; } let tool_call_id = data .pointer("/properties/tool/callID") .and_then(Value::as_str) .map(str::trim) .filter(|id| !id.is_empty()) .unwrap_or(request_id.as_str()) .to_string(); let questions = data .pointer("/properties/questions") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let question_count = questions.len().max(1); let approvals = ctx.approvals.clone(); let client = ctx.client.clone(); let base_url = ctx.base_url.to_string(); let directory = ctx.directory.to_string(); let log_writer = ctx.log_writer.clone(); let cancel = ctx.cancel.clone(); let done_tx = ctx.pending_approvals.push().await; tokio::spawn(async move { let status = match create_question_approval(approvals.clone(), question_count) .await { Ok(created) => { let _ = log_writer .log_event(&OpencodeExecutorEvent::QuestionAsked { tool_call_id: tool_call_id.clone(), approval_id: created.approval_id.clone(), }) .await; match wait_question_approval(approvals, &created.approval_id, cancel) .await { Ok(status) => Some(status), Err(err) => { handle_approval_error( err, &format!("OpenCode question approval wait failed for request_id={request_id}"), &log_writer, &tool_call_id, true, ).await; None } } } Err(err) => { handle_approval_error( err, &format!("OpenCode question approval create failed for request_id={request_id}"), &log_writer, &tool_call_id, true, ).await; None } }; if let Some(status) = status { log_question_response(&log_writer, &tool_call_id, status.clone()).await; match status { QuestionStatus::Answered { answers } => { let opencode_answers = answers_to_opencode_format(&questions, &answers); let resp = client .post(format!("{base_url}/question/{request_id}/reply")) .query(&[("directory", directory.as_str())]) .json(&serde_json::json!({ "answers": opencode_answers })) .send() .await; match resp { Ok(resp) if !resp.status().is_success() => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let truncated: String = body.chars().take(400).collect::(); tracing::warn!( "OpenCode question reply failed request_id={} status={} body={}", request_id, status, truncated ); } Ok(_) => {} Err(err) => { let is_timeout = err.is_timeout(); tracing::warn!( "OpenCode question reply error request_id={} timeout={}: {err}", request_id, is_timeout ); } } } QuestionStatus::TimedOut => { let _ = client .post(format!("{base_url}/question/{request_id}/reject")) .query(&[("directory", directory.as_str())]) .send() .await; } } } let _ = done_tx.send(()); }); } "permission.asked" => { let request_id = data .pointer("/properties/id") .and_then(Value::as_str) .unwrap_or_default() .to_string(); if request_id.is_empty() || !ctx.seen_permissions.insert(request_id.clone()) { continue; } let tool_call_id = data .pointer("/properties/tool/callID") .and_then(Value::as_str) .unwrap_or(&request_id) .to_string(); let permission = data .pointer("/properties/permission") .and_then(Value::as_str) .unwrap_or("tool") .to_string(); let approvals = ctx.approvals.clone(); let client = ctx.client.clone(); let base_url = ctx.base_url.to_string(); let directory = ctx.directory.to_string(); let log_writer = ctx.log_writer.clone(); let auto_approve = ctx.auto_approve; let cancel = ctx.cancel.clone(); let done_tx = ctx.pending_approvals.push().await; tokio::spawn(async move { let created = match create_permission_approval( auto_approve, approvals.clone(), &permission, ) .await { Ok(Some(created)) => created, Ok(None) => { // Auto-approved, no approval needed log_approval_response( &log_writer, &tool_call_id, ApprovalStatus::Approved, ) .await; let _ = client .post(format!("{base_url}/permission/{request_id}/reply")) .query(&[("directory", directory.as_str())]) .json(&serde_json::json!({ "reply": "once" })) .send() .await; let _ = done_tx.send(()); return; } Err(err) => { handle_approval_error( err, &format!("OpenCode approval create failed for tool_call_id={tool_call_id}"), &log_writer, &tool_call_id, false, ).await; let _ = done_tx.send(()); return; } }; let _ = log_writer .log_event(&OpencodeExecutorEvent::ApprovalRequested { tool_call_id: tool_call_id.clone(), approval_id: created.approval_id.clone(), }) .await; let status = match wait_permission_approval(approvals, &created.approval_id, cancel) .await { Ok(status) => status, Err(err) => { handle_approval_error( err, &format!( "OpenCode approval wait failed for tool_call_id={tool_call_id}" ), &log_writer, &tool_call_id, false, ) .await; let _ = done_tx.send(()); return; } }; log_approval_response(&log_writer, &tool_call_id, status.clone()).await; let (reply, message) = match status { ApprovalStatus::Approved => ("once", None), ApprovalStatus::Denied { reason } => { let msg = reason .unwrap_or_else(|| "User denied this tool use request".to_string()) .trim() .to_string(); let msg = if msg.is_empty() { "User denied this tool use request".to_string() } else { msg }; ("reject", Some(msg)) } ApprovalStatus::TimedOut => ( "reject", Some( "Approval request timed out; proceed without using this tool call." .to_string(), ), ), ApprovalStatus::Pending => ( "reject", Some( "Approval request could not be completed; proceed without using this tool call." .to_string(), ), ), }; // If we reject without a message, OpenCode treats it as a hard stop. // Provide a message so the agent can continue with guidance. let payload = if reply == "reject" { serde_json::json!({ "reply": reply, "message": message.unwrap_or_else(|| "User denied this tool use request".to_string()) }) } else { serde_json::json!({ "reply": reply }) }; let _ = client .post(format!("{base_url}/permission/{request_id}/reply")) .query(&[("directory", directory.as_str())]) .json(&payload) .send() .await; let _ = done_tx.send(()); }); } _ => {} } } Ok(EventStreamOutcome::Disconnected) } fn event_matches_session(event_type: &str, event: &Value, session_id: &str) -> bool { let extracted = match event_type { "message.updated" => event .pointer("/properties/info/sessionID") .and_then(Value::as_str), "message.part.updated" => event .pointer("/properties/part/sessionID") .and_then(Value::as_str), "message.part.delta" => event .pointer("/properties/sessionID") .and_then(Value::as_str), "permission.asked" | "permission.replied" | "question.asked" | "question.replied" | "question.rejected" | "session.idle" | "session.error" => event .pointer("/properties/sessionID") .and_then(Value::as_str), _ => event .pointer("/properties/sessionID") .and_then(Value::as_str) .or_else(|| { event .pointer("/properties/info/sessionID") .and_then(Value::as_str) }) .or_else(|| { event .pointer("/properties/part/sessionID") .and_then(Value::as_str) }), }; extracted == Some(session_id) } async fn handle_approval_error( err: ExecutorApprovalError, error_context: &str, log_writer: &LogWriter, tool_call_id: &str, is_question: bool, ) { if matches!(err, ExecutorApprovalError::Cancelled) { return; } tracing::error!("{error_context}: {err}"); if is_question { log_question_response(log_writer, tool_call_id, QuestionStatus::TimedOut).await; } else { log_approval_response( log_writer, tool_call_id, ApprovalStatus::Denied { reason: Some(format!("Approval service error: {err}")), }, ) .await; } } async fn log_approval_response(log_writer: &LogWriter, tool_call_id: &str, status: ApprovalStatus) { let _ = log_writer .log_event(&OpencodeExecutorEvent::ApprovalResponse { tool_call_id: tool_call_id.to_string(), status, }) .await; } async fn log_question_response(log_writer: &LogWriter, tool_call_id: &str, status: QuestionStatus) { let _ = log_writer .log_event(&OpencodeExecutorEvent::QuestionResponse { tool_call_id: tool_call_id.to_string(), status, }) .await; } struct ApprovalCreated { approval_id: String, } async fn create_permission_approval( auto_approve: bool, approvals: Option>, tool_name: &str, ) -> Result, ExecutorApprovalError> { if auto_approve { return Ok(None); } let Some(approvals) = approvals else { return Ok(None); }; match approvals.create_tool_approval(tool_name).await { Ok(approval_id) => Ok(Some(ApprovalCreated { approval_id })), Err( ExecutorApprovalError::ServiceUnavailable | ExecutorApprovalError::SessionNotRegistered, ) => Ok(None), Err(err) => Err(err), } } async fn wait_permission_approval( approvals: Option>, approval_id: &str, cancel: CancellationToken, ) -> Result { let Some(approvals) = approvals else { return Ok(ApprovalStatus::Approved); }; approvals.wait_tool_approval(approval_id, cancel).await } async fn create_question_approval( approvals: Option>, question_count: usize, ) -> Result { let Some(approvals) = approvals else { return Err(ExecutorApprovalError::ServiceUnavailable); }; let approval_id = approvals .create_question_approval("question", question_count) .await?; Ok(ApprovalCreated { approval_id }) } async fn wait_question_approval( approvals: Option>, approval_id: &str, cancel: CancellationToken, ) -> Result { let Some(approvals) = approvals else { return Err(ExecutorApprovalError::ServiceUnavailable); }; approvals.wait_question_answer(approval_id, cancel).await } fn answers_to_opencode_format(questions: &[Value], answers: &[QuestionAnswer]) -> Vec> { questions .iter() .map(|q| { let question_text = q.get("question").and_then(Value::as_str).unwrap_or(""); answers .iter() .find(|qa| qa.question == question_text) .map(|qa| qa.answer.clone()) .unwrap_or_else(|| { tracing::warn!( ?questions, ?answers, "No answer found for question: {question_text}. This may cause issues with OpenCode processing the reply." ); vec![] }) }) .collect() } ================================================ FILE: crates/executors/src/executors/opencode/slash_commands.rs ================================================ //! OpenCode slash command parsing, execution, and result formatting. //! //! This module is the central place for all slash command handling in OpenCode. //! It defines the command enum, parses prompts, executes commands via the SDK, //! and formats results as markdown. use std::{collections::HashMap, future::Future, io, pin::Pin}; use serde_json::Value; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use super::{ sdk::{ self, AgentInfo, CommandInfo, ConfigProvidersResponse, ConfigResponse, ControlEvent, EventListenerConfig, FormatterStatus, LogWriter, LspStatus, RunConfig, }, types::{OpencodeExecutorEvent, ProviderListResponse}, }; use crate::executors::{ ExecutorError, SlashCommandDescription, utils::{SlashCommandCall, parse_slash_command}, }; /// OpenCode slash command with known variants and custom fallback. #[derive(Debug, Clone)] pub enum OpencodeSlashCommand { Compact, Commands, Models { provider: Option, }, Agents, Status, Mcp, /// A custom command not in the built-in list. Custom { name: String, arguments: String, }, } impl OpencodeSlashCommand { /// Parse a prompt string into a slash command. pub fn parse(prompt: &str) -> Option { parse_slash_command(prompt) } /// Returns true if this command requires an existing session. pub fn requires_existing_session(&self) -> bool { matches!(self, Self::Compact) } /// Returns true if this command should fork the session. pub fn should_fork_session(&self) -> bool { true } } impl<'a> From> for OpencodeSlashCommand { fn from(call: SlashCommandCall<'a>) -> Self { match call.name.as_str() { "compact" | "summarize" => Self::Compact, "commands" => Self::Commands, "models" => Self::Models { provider: call.arguments.split_whitespace().next().map(String::from), }, "agents" => Self::Agents, "status" => Self::Status, "mcp" => Self::Mcp, _ => Self::Custom { name: call.name, arguments: call.arguments.to_string(), }, } } } /// Build the list of hardcoded slash commands for discovery. pub fn hardcoded_slash_commands() -> Vec { vec![ SlashCommandDescription { name: "compact".to_string(), description: Some("compact the session".to_string()), }, SlashCommandDescription { name: "commands".to_string(), description: Some("show all commands".to_string()), }, SlashCommandDescription { name: "models".to_string(), description: Some("list models".to_string()), }, SlashCommandDescription { name: "agents".to_string(), description: Some("list agents".to_string()), }, SlashCommandDescription { name: "status".to_string(), description: Some("show status".to_string()), }, SlashCommandDescription { name: "mcp".to_string(), description: Some("show MCP status".to_string()), }, ] } /// Format a list of commands as markdown. fn format_commands(commands: &[CommandInfo]) -> String { if commands.is_empty() { return "_No commands available._".to_string(); } let mut sorted = commands.to_vec(); sorted.sort_by(|a, b| a.name.cmp(&b.name)); let mut lines = vec!["## Available Commands".to_string(), String::new()]; for command in sorted { let name = command.name.strip_prefix('/').unwrap_or(&command.name); let desc = command .description .as_ref() .filter(|d| !d.trim().is_empty()) .map(|d| format!(" — {d}")) .unwrap_or_default(); lines.push(format!("- `/{name}`{desc}")); } lines.join("\n") } /// Format a list of agents as markdown. fn format_agents(agents: &[AgentInfo]) -> String { if agents.is_empty() { return "_No agents available._".to_string(); } let mut sorted = agents.to_vec(); sorted.sort_by(|a, b| a.name.cmp(&b.name)); let mut lines = vec!["## Available Agents".to_string(), String::new()]; for agent in sorted { let desc = agent .description .as_ref() .filter(|d| !d.trim().is_empty()) .map(|d| format!(" — {d}")) .unwrap_or_default(); lines.push(format!("- **{}**{desc}", agent.name)); } lines.join("\n") } /// Format models list as markdown. fn format_models( config_providers: &ConfigProvidersResponse, provider_list: Option<&ProviderListResponse>, provider_filter: Option<&str>, ) -> String { let mut providers: Vec<_> = config_providers.providers.iter().collect(); providers.sort_by(|a, b| a.id.cmp(&b.id)); if providers.is_empty() { return "_No models available._".to_string(); } if let Some(filter) = provider_filter && !providers.iter().any(|p| p.id == filter) { return format!("_Provider not found: `{filter}`_"); } let mut lines = vec!["## Models".to_string(), String::new()]; for provider in providers { if let Some(filter) = provider_filter && provider.id != filter { continue; } let default_note = config_providers .default .get(&provider.id) .map(|m| format!(" (default: `{m}`)")) .unwrap_or_default(); lines.push(format!("### {}{default_note}", provider.id)); lines.push(String::new()); let mut model_ids: Vec<_> = provider.models.keys().cloned().collect(); model_ids.sort(); for model_id in model_ids { lines.push(format!("- `{}/{model_id}`", provider.id)); } lines.push(String::new()); } if let Some(list) = provider_list && !list.connected.is_empty() { let mut connected = list.connected.clone(); connected.sort(); lines.push(format!("**Connected:** {}", connected.join(", "))); } lines.join("\n").trim_end().to_string() } /// Format status information as markdown. fn format_status( mcp: &HashMap, lsp: &[LspStatus], formatter: &[FormatterStatus], config: &ConfigResponse, ) -> String { let mut sections = Vec::new(); sections.push(format_mcp_section(mcp)); sections.push(format_lsp_section(lsp)); sections.push(format_formatter_section(formatter)); let plugins = if config.plugin.is_empty() { "**Plugins:** _none_".to_string() } else { format!("**Plugins:** {}", config.plugin.join(", ")) }; sections.push(plugins); sections.join("\n\n") } /// Format MCP status as markdown. fn format_mcp(mcp: &HashMap) -> String { format_mcp_section(mcp) } fn format_mcp_section(mcp: &HashMap) -> String { let mut lines = vec!["### MCP Servers".to_string(), String::new()]; if mcp.is_empty() { lines.push("_No MCP servers configured._".to_string()); } else { let mut names: Vec<_> = mcp.keys().cloned().collect(); names.sort(); for name in names { let entry = mcp.get(&name).unwrap_or(&Value::Null); let status = entry .get("status") .and_then(Value::as_str) .unwrap_or("unknown"); let error_note = entry .get("error") .and_then(Value::as_str) .map(|e| format!(" — _{e}_")) .unwrap_or_default(); lines.push(format!("- **{name}**: {status}{error_note}")); } } lines.join("\n") } fn format_lsp_section(lsp: &[LspStatus]) -> String { let mut lines = vec!["### LSP Servers".to_string(), String::new()]; if lsp.is_empty() { lines.push("_No LSP servers active._".to_string()); } else { let mut entries = lsp.to_vec(); entries.sort_by(|a, b| a.name.cmp(&b.name)); for entry in entries { lines.push(format!( "- **{}** ({}) — `{}`", entry.name, entry.status, entry.root )); } } lines.join("\n") } fn format_formatter_section(formatter: &[FormatterStatus]) -> String { let mut lines = vec!["### Formatters".to_string(), String::new()]; if formatter.is_empty() { lines.push("_No formatters configured._".to_string()); } else { let mut entries = formatter.to_vec(); entries.sort_by(|a, b| a.name.cmp(&b.name)); for entry in entries { let status = if entry.enabled { "enabled" } else { "disabled" }; let extensions = if entry.extensions.is_empty() { String::new() } else { format!(" — {}", entry.extensions.join(", ")) }; lines.push(format!("- **{}** [{status}]{extensions}", entry.name)); } } lines.join("\n") } /// Format a "command not found" message. fn format_command_not_found(name: &str) -> String { format!("_Command not found: `/{name}`_") } /// Format a "no session" message. fn format_no_session() -> String { "_No session available to run this command yet._".to_string() } /// Log a slash command result as an event. async fn log_result(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> { log_writer.log_slash_command_result(message).await } /// Log completion of a slash command. async fn log_done(log_writer: &LogWriter) -> Result<(), ExecutorError> { log_writer.log_event(&OpencodeExecutorEvent::Done).await } /// Log a result and mark as done. async fn log_result_and_done(log_writer: &LogWriter, message: String) -> Result<(), ExecutorError> { log_result(log_writer, message).await?; log_done(log_writer).await } /// Execute a slash command using the OpenCode SDK. pub async fn execute( config: RunConfig, command: OpencodeSlashCommand, log_writer: LogWriter, client: reqwest::Client, cancel: CancellationToken, ) -> Result<(), ExecutorError> { tokio::select! { _ = cancel.cancelled() => return Ok(()), res = sdk::wait_for_health(&client, &config.base_url) => res?, } // Handle commands that don't require a session first match &command { OpencodeSlashCommand::Commands => { let commands = sdk::list_commands(&client, &config.base_url, &config.directory).await?; log_result_and_done(&log_writer, format_commands(&commands)).await?; return Ok(()); } OpencodeSlashCommand::Models { provider } => { let config_providers = sdk::list_config_providers(&client, &config.base_url, &config.directory).await?; let provider_list = sdk::list_providers(&client, &config.base_url, &config.directory) .await .ok(); log_result_and_done( &log_writer, format_models( &config_providers, provider_list.as_ref(), provider.as_deref(), ), ) .await?; return Ok(()); } OpencodeSlashCommand::Agents => { let agents = sdk::list_agents(&client, &config.base_url, &config.directory).await?; log_result_and_done(&log_writer, format_agents(&agents)).await?; return Ok(()); } OpencodeSlashCommand::Status => { let mcp = sdk::mcp_status(&client, &config.base_url, &config.directory).await?; let lsp = sdk::lsp_status(&client, &config.base_url, &config.directory).await?; let formatter = sdk::formatter_status(&client, &config.base_url, &config.directory).await?; let cfg = sdk::config_get(&client, &config.base_url, &config.directory).await?; log_result_and_done(&log_writer, format_status(&mcp, &lsp, &formatter, &cfg)).await?; return Ok(()); } OpencodeSlashCommand::Mcp => { let mcp = sdk::mcp_status(&client, &config.base_url, &config.directory).await?; log_result_and_done(&log_writer, format_mcp(&mcp)).await?; return Ok(()); } // Session-dependent commands handled below OpencodeSlashCommand::Compact | OpencodeSlashCommand::Custom { .. } => {} } // Validate custom commands exist if let OpencodeSlashCommand::Custom { name, .. } = &command { let available = sdk::list_commands(&client, &config.base_url, &config.directory).await?; let normalized = name.trim_start_matches('/'); if !available .iter() .any(|cmd| cmd.name.trim_start_matches('/') == normalized) { log_result_and_done(&log_writer, format_command_not_found(normalized)).await?; return Ok(()); } } if command.requires_existing_session() && config.resume_session_id.is_none() { log_writer .log_slash_command_result(format_no_session()) .await?; log_writer.log_event(&OpencodeExecutorEvent::Done).await?; return Ok(()); } let session_id = match config.resume_session_id.as_deref() { Some(existing) if command.should_fork_session() => { tokio::select! { _ = cancel.cancelled() => return Ok(()), res = sdk::fork_session(&client, &config.base_url, &config.directory, existing) => res?, } } Some(existing) => existing.to_string(), None => tokio::select! { _ = cancel.cancelled() => return Ok(()), res = sdk::create_session(&client, &config.base_url, &config.directory) => res?, }, }; log_writer .log_event(&OpencodeExecutorEvent::SessionStart { session_id: session_id.clone(), }) .await?; let is_compact = matches!(&command, OpencodeSlashCommand::Compact); let compaction_model = if is_compact { Some( sdk::resolve_compaction_model( &client, &config.base_url, &config.directory, config.model.as_deref(), ) .await?, ) } else { None }; let (control_tx, mut control_rx) = mpsc::unbounded_channel::(); let pending_approvals = sdk::PendingApprovals::new(); let event_resp = tokio::select! { _ = cancel.cancelled() => return Ok(()), res = sdk::connect_event_stream(&client, &config.base_url, &config.directory, None) => res?, }; let event_handle = tokio::spawn(sdk::spawn_event_listener( EventListenerConfig { client: client.clone(), base_url: config.base_url.clone(), directory: config.directory.clone(), session_id: session_id.clone(), log_writer: log_writer.clone(), approvals: config.approvals.clone(), auto_approve: config.auto_approve, control_tx, pending_approvals: pending_approvals.clone(), models_cache_key: config.models_cache_key.clone(), cancel: cancel.clone(), }, event_resp, )); let request_client = client.clone(); let request_base_url = config.base_url.clone(); let request_directory = config.directory.clone(); let request_session_id = session_id.clone(); let request_agent = config.agent.clone(); let request_model = config.model.clone(); let request_model_variant = config.model_variant.clone(); let request_fut: Pin> + Send>> = match command { OpencodeSlashCommand::Compact => { let model = compaction_model.ok_or_else(|| { ExecutorError::Io(io::Error::other("OpenCode compaction model missing")) })?; Box::pin(async move { sdk::session_summarize( &request_client, &request_base_url, &request_directory, &request_session_id, model, ) .await }) } OpencodeSlashCommand::Custom { name, arguments } => Box::pin(async move { sdk::session_command( &request_client, &request_base_url, &request_directory, &request_session_id, name, arguments, request_agent, request_model, request_model_variant, ) .await }), _ => unreachable!("handled non-session commands earlier"), }; let request_result = sdk::run_request_with_control( request_fut, &mut control_rx, &pending_approvals, cancel.clone(), ) .await; if cancel.is_cancelled() { sdk::send_abort(&client, &config.base_url, &config.directory, &session_id).await; event_handle.abort(); return Ok(()); } event_handle.abort(); request_result?; log_writer.log_event(&OpencodeExecutorEvent::Done).await?; Ok(()) } ================================================ FILE: crates/executors/src/executors/opencode/types.rs ================================================ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use workspace_utils::approvals::{ApprovalStatus, QuestionStatus}; /// JSON log events emitted by the OpenCode SDK executor. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum OpencodeExecutorEvent { StartupLog { message: String, }, SessionStart { session_id: String, }, SlashCommandResult { message: String, }, SdkEvent { event: serde_json::Value, }, TokenUsage { total_tokens: u32, model_context_window: u32, }, ApprovalRequested { tool_call_id: String, approval_id: String, }, ApprovalResponse { tool_call_id: String, status: ApprovalStatus, }, QuestionAsked { tool_call_id: String, approval_id: String, }, QuestionResponse { tool_call_id: String, status: QuestionStatus, }, SystemMessage { content: String, }, Error { message: String, }, Done, } #[derive(Debug, Deserialize)] pub(super) struct SdkEventEnvelope { #[serde(rename = "type")] pub(super) type_: String, #[serde(default)] pub(super) properties: Value, } #[derive(Debug)] pub(super) enum SdkEvent { MessageUpdated(MessageUpdatedEvent), MessagePartUpdated(MessagePartUpdatedEvent), MessagePartDelta(MessagePartDeltaEvent), MessageRemoved, MessagePartRemoved, PermissionAsked(PermissionAskedEvent), PermissionReplied, SessionIdle, SessionStatus(SessionStatusEvent), SessionDiff, SessionCompacted, SessionError(SessionErrorEvent), TodoUpdated(TodoUpdatedEvent), QuestionAsked(QuestionAskedEvent), QuestionReplied, QuestionRejected, CommandExecuted, TuiSessionSelect, Unknown { type_: String, properties: Value }, } impl SdkEvent { pub(super) fn parse(value: &Value) -> Option { let envelope = serde_json::from_value::(value.clone()).ok()?; let event = match envelope.type_.as_str() { "message.updated" => { SdkEvent::MessageUpdated(serde_json::from_value(envelope.properties).ok()?) } "message.part.updated" => { SdkEvent::MessagePartUpdated(serde_json::from_value(envelope.properties).ok()?) } "message.part.delta" => { SdkEvent::MessagePartDelta(serde_json::from_value(envelope.properties).ok()?) } "message.removed" => SdkEvent::MessageRemoved, "message.part.removed" => SdkEvent::MessagePartRemoved, "permission.asked" => { SdkEvent::PermissionAsked(serde_json::from_value(envelope.properties).ok()?) } "permission.replied" => SdkEvent::PermissionReplied, "session.idle" => SdkEvent::SessionIdle, "session.status" => { SdkEvent::SessionStatus(serde_json::from_value(envelope.properties).ok()?) } "session.diff" => SdkEvent::SessionDiff, "session.compacted" => SdkEvent::SessionCompacted, "session.error" => { SdkEvent::SessionError(serde_json::from_value(envelope.properties).ok()?) } "todo.updated" => { SdkEvent::TodoUpdated(serde_json::from_value(envelope.properties).ok()?) } "question.asked" => { SdkEvent::QuestionAsked(serde_json::from_value(envelope.properties).ok()?) } "question.replied" => SdkEvent::QuestionReplied, "question.rejected" => SdkEvent::QuestionRejected, "command.executed" => SdkEvent::CommandExecuted, "tui.session.select" => SdkEvent::TuiSessionSelect, _ => SdkEvent::Unknown { type_: envelope.type_, properties: envelope.properties, }, }; Some(event) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub(super) enum MessageRole { User, Assistant, } #[derive(Debug, Deserialize)] pub(super) struct MessageUpdatedEvent { pub(super) info: MessageInfo, } #[derive(Debug, Deserialize)] pub(super) struct MessageInfo { pub(super) id: String, pub(super) role: MessageRole, #[serde(default)] pub(super) model: Option, #[serde(rename = "providerID", default)] pub(super) provider_id: Option, #[serde(rename = "modelID", default)] pub(super) model_id: Option, #[serde(default)] pub(super) tokens: Option, } #[derive(Debug, Deserialize)] pub(super) struct MessageTokens { #[serde(default, deserialize_with = "deserialize_f64_as_u32")] pub(super) input: u32, #[serde(default, deserialize_with = "deserialize_f64_as_u32")] pub(super) output: u32, pub(super) cache: Option, } #[derive(Debug, Deserialize)] pub(super) struct MessageTokensCache { #[serde(default, deserialize_with = "deserialize_f64_as_u32")] pub(super) read: u32, } fn deserialize_f64_as_u32<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let v = Option::::deserialize(deserializer)?; Ok(v.filter(|f| f.is_finite() && *f >= 0.0) .map(|f| f.round() as u32) .unwrap_or(0)) } impl MessageInfo { pub(super) fn provider_id(&self) -> Option<&str> { self.model .as_ref() .map(|m| m.provider_id.as_str()) .or(self.provider_id.as_deref()) } pub(super) fn model_id(&self) -> Option<&str> { self.model .as_ref() .map(|m| m.model_id.as_str()) .or(self.model_id.as_deref()) } } #[derive(Debug, Deserialize)] pub(super) struct MessageModelInfo { #[serde(rename = "providerID", alias = "providerId")] pub(super) provider_id: String, #[serde(rename = "modelID", alias = "modelId")] pub(super) model_id: String, } #[derive(Debug, Deserialize)] pub(super) struct MessagePartUpdatedEvent { pub(super) part: Part, #[serde(default)] pub(super) delta: Option, } #[derive(Debug, Deserialize)] pub(super) struct MessagePartDeltaEvent { #[allow(dead_code)] #[serde(rename = "messageID")] pub(super) message_id: String, #[serde(rename = "partID")] pub(super) part_id: String, pub(super) field: String, pub(super) delta: String, } #[derive(Debug, Deserialize)] pub(super) struct PermissionAskedEvent { #[allow(dead_code)] pub(super) id: String, pub(super) permission: String, #[serde(default)] pub(super) patterns: Vec, #[serde(default)] pub(super) metadata: Value, #[serde(default)] pub(super) tool: Option, } #[derive(Debug, Deserialize)] pub(super) struct PermissionToolInfo { #[serde(rename = "callID")] pub(super) call_id: String, } #[derive(Debug, Deserialize)] pub(super) struct QuestionAskedEvent { pub(super) id: String, pub(super) questions: Vec, #[serde(default)] pub(super) tool: Option, } #[derive(Debug, Deserialize)] pub(super) struct QuestionAskedTool { #[allow(dead_code)] #[serde(rename = "messageID")] pub(super) message_id: String, #[serde(rename = "callID")] pub(super) call_id: String, } #[derive(Debug, Serialize, Deserialize)] pub(super) struct QuestionInfo { pub(super) question: String, pub(super) header: String, #[serde(default)] pub(super) options: Vec, #[serde(default)] pub(super) multiple: Option, } #[derive(Debug, Serialize, Deserialize)] pub(super) struct QuestionOption { pub(super) label: String, #[serde(default)] pub(super) description: String, } #[derive(Debug, Deserialize)] pub(super) struct SessionStatusEvent { pub(super) status: SessionStatus, } #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub(super) enum SessionStatus { Idle, Busy, Retry { attempt: u64, message: String, next: u64, }, #[serde(other)] Other, } #[derive(Debug, Deserialize)] pub(super) struct TodoUpdatedEvent { pub(super) todos: Vec, } #[derive(Debug, Deserialize)] pub(super) struct SdkTodo { #[serde(default)] pub(super) id: Option, pub(super) content: String, pub(super) status: String, pub(super) priority: String, } #[derive(Debug, Deserialize)] #[serde(tag = "type")] pub(super) enum Part { #[serde(rename = "text")] Text(TextPart), #[serde(rename = "reasoning")] Reasoning(ReasoningPart), #[serde(rename = "tool")] Tool(Box), #[serde(other)] Other, } #[derive(Debug, Deserialize)] pub(super) struct TextPart { #[serde(default)] pub(super) id: Option, #[serde(rename = "messageID")] pub(super) message_id: String, pub(super) text: String, } /// Same structure as TextPart, used for reasoning content pub(super) type ReasoningPart = TextPart; #[derive(Debug, Deserialize)] pub(super) struct ToolPart { #[serde(rename = "messageID")] pub(super) message_id: String, #[serde(rename = "callID")] pub(super) call_id: String, #[serde(default)] pub(super) tool: String, pub(super) state: ToolStateUpdate, } #[derive(Debug, Deserialize)] #[serde(tag = "status", rename_all = "lowercase")] pub(super) enum ToolStateUpdate { Pending { #[serde(default)] input: Option, }, Running { #[serde(default)] input: Option, #[serde(default)] title: Option, #[serde(default)] metadata: Option, }, Completed { #[serde(default)] input: Option, #[serde(default)] output: Option, #[serde(default)] title: Option, #[serde(default)] metadata: Option, }, Error { #[serde(default)] input: Option, #[serde(default)] error: Option, #[serde(default)] metadata: Option, }, #[serde(other)] Unknown, } #[derive(Debug, Deserialize)] pub(super) struct SessionErrorEvent { #[serde(default)] pub(super) error: Option, } #[derive(Debug)] pub(super) struct SdkError { pub(super) raw: Value, } impl SdkError { pub(super) fn kind(&self) -> &str { self.raw .get("name") .or_else(|| self.raw.get("type")) .and_then(Value::as_str) .unwrap_or("unknown") } pub(super) fn message(&self) -> Option { self.raw .pointer("/data/message") .or_else(|| self.raw.get("message")) .and_then(Value::as_str) .map(|s| s.to_string()) } } impl<'de> Deserialize<'de> for SdkError { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let raw = Value::deserialize(deserializer)?; Ok(Self { raw }) } } /// Configuration response from /config endpoint #[derive(Debug, Deserialize)] pub(super) struct Config { #[serde(default)] pub(super) model: Option, } #[derive(Debug, Deserialize, Default)] pub struct ProviderModelInfo { #[serde(default)] pub id: String, #[serde(default)] pub name: String, #[serde(default)] pub release_date: Option, #[serde(default)] pub variants: Option>, #[serde(default)] pub limit: ProviderModelLimit, } #[derive(Debug, Deserialize, Default)] pub struct ProviderModelLimit { #[serde(default, deserialize_with = "deserialize_f64_as_u32")] pub context: u32, } #[derive(Debug, Deserialize)] pub struct ProviderInfo { pub id: String, #[serde(default)] pub name: String, #[serde(default)] pub models: HashMap, } #[derive(Debug, Deserialize)] pub struct ProviderListResponse { pub all: Vec, #[serde(default)] pub connected: Vec, } ================================================ FILE: crates/executors/src/executors/opencode.rs ================================================ use std::{cmp::Ordering, path::Path, sync::Arc, time::Duration}; use async_trait::async_trait; use command_group::AsyncGroupChild; use convert_case::{Case, Casing}; use derivative::Derivative; use futures::StreamExt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tokio::{io::AsyncBufReadExt, process::Command}; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore}; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::{ExecutionEnv, RepoContext}, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, ExecutorExitResult, SlashCommandDescription, SpawnedChild, StandardCodingAgentExecutor, opencode::types::OpencodeExecutorEvent, utils::reorder_slash_commands, }, logs::utils::patch, model_selector::{AgentInfo, ModelInfo, ModelProvider, PermissionPolicy, ReasoningOption}, profile::ExecutorConfig, stdout_dup::create_stdout_pipe_writer, }; mod models; mod normalize_logs; pub(crate) mod sdk; mod slash_commands; pub(crate) mod types; use sdk::{ AgentInfo as SDKAgentInfo, LogWriter, RunConfig, build_authenticated_client, generate_server_password, list_agents, list_commands, list_providers, run_session, run_slash_command, }; use slash_commands::{OpencodeSlashCommand, hardcoded_slash_commands}; use types::{Config, ProviderModelInfo}; #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct Opencode { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub variant: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "mode")] pub agent: Option, /// Auto-approve agent actions #[serde(default = "default_to_true")] pub auto_approve: bool, /// Enable auto-compaction when the context length approaches the model's context window limit #[serde(default = "default_to_true")] pub auto_compact: bool, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub approvals: Option>, } /// Represents a spawned OpenCode server with its base URL struct OpencodeServer { #[allow(unused)] child: Option, base_url: String, server_password: ServerPassword, } impl Drop for OpencodeServer { fn drop(&mut self) { // kill the process properly using the kill helper as the native kill_on_drop doesn't work reliably causing orphaned processes and memory leaks if let Some(mut child) = self.child.take() { tokio::spawn(async move { let _ = workspace_utils::process::kill_process_group(&mut child).await; }); } } } type ServerPassword = String; impl Opencode { fn build_command_builder(&self) -> Result { let builder = CommandBuilder::new("npx -y opencode-ai@1.2.27") // Pass hostname/port as separate args so OpenCode treats them as explicitly set // (it checks `process.argv.includes(\"--port\")` / `\"--hostname\"`). .extend_params(["serve", "--hostname", "127.0.0.1", "--port", "0"]); apply_overrides(builder, &self.cmd) } /// Compute a cache key for model context windows based on configuration that can affect the list of available models. fn compute_models_cache_key(&self) -> String { serde_json::to_string(&self.cmd).unwrap_or_default() } /// Common boilerplate for spawning an OpenCode server process. async fn spawn_server_process( &self, current_dir: &Path, env: &ExecutionEnv, ) -> Result<(AsyncGroupChild, ServerPassword), ExecutorError> { let command_parts = self.build_command_builder()?.build_initial()?; let (program_path, args) = command_parts.into_resolved().await?; let server_password = generate_server_password(); let mut command = Command::new(program_path); command .kill_on_drop(true) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .current_dir(current_dir) .env("NPM_CONFIG_LOGLEVEL", "error") .env("NODE_NO_WARNINGS", "1") .env("NO_COLOR", "1") .env("OPENCODE_SERVER_USERNAME", "opencode") .env("OPENCODE_SERVER_PASSWORD", &server_password) .args(&args); env.clone() .with_profile(&self.cmd) .apply_to_command(&mut command); let child = command.group_spawn_no_window()?; Ok((child, server_password)) } /// Handles process spawning, waiting for the server URL async fn spawn_server( &self, current_dir: &Path, env: &ExecutionEnv, ) -> Result { let (mut child, server_password) = self.spawn_server_process(current_dir, env).await?; let server_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("OpenCode server missing stdout")) })?; let base_url = wait_for_server_url(server_stdout, None).await?; Ok(OpencodeServer { child: Some(child), base_url, server_password, }) } async fn spawn_inner( &self, current_dir: &Path, prompt: &str, resume_session: Option<&str>, env: &ExecutionEnv, ) -> Result { let slash_command = OpencodeSlashCommand::parse(prompt); let combined_prompt = if slash_command.is_some() { prompt.to_string() } else { self.append_prompt.combine_prompt(prompt) }; let (mut child, server_password) = self.spawn_server_process(current_dir, env).await?; let server_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::other("OpenCode server missing stdout")) })?; let stdout = create_stdout_pipe_writer(&mut child)?; let log_writer = LogWriter::new(stdout); let (exit_signal_tx, exit_signal_rx) = tokio::sync::oneshot::channel(); let cancel = tokio_util::sync::CancellationToken::new(); // Prepare config values that will be moved into the spawned task let directory = current_dir.to_string_lossy().to_string(); let approvals = self.approvals.clone(); let model = self.model.clone(); let model_variant = self.variant.clone(); let agent = self.agent.clone(); let auto_approve = self.auto_approve; let resume_session_id = resume_session.map(|s| s.to_string()); let models_cache_key = self.compute_models_cache_key(); let cancel_for_task = cancel.clone(); let commit_reminder = env.commit_reminder; let commit_reminder_prompt = env.commit_reminder_prompt.clone(); let repo_context = env.repo_context.clone(); tokio::spawn(async move { // Wait for server to print listening URL let base_url = match wait_for_server_url(server_stdout, Some(log_writer.clone())).await { Ok(url) => url, Err(err) => { let _ = log_writer .log_error(format!("OpenCode startup error: {err}")) .await; let _ = exit_signal_tx.send(ExecutorExitResult::Failure); return; } }; let config = RunConfig { base_url, directory, prompt: combined_prompt, resume_session_id, model, model_variant, agent, approvals, auto_approve, server_password, models_cache_key, commit_reminder, commit_reminder_prompt, repo_context, }; let result = match slash_command { Some(command) => { run_slash_command(config, log_writer.clone(), command, cancel_for_task).await } None => run_session(config, log_writer.clone(), cancel_for_task).await, }; let exit_result = match result { Ok(()) => ExecutorExitResult::Success, Err(err) => { let _ = log_writer .log_error(format!("OpenCode executor error: {err}")) .await; ExecutorExitResult::Failure } }; let _ = exit_signal_tx.send(exit_result); }); Ok(SpawnedChild { child, exit_signal: Some(exit_signal_rx), cancel: Some(cancel), }) } /// Transform raw model data into ModelInfo structs. fn transform_models( &self, models: &std::collections::HashMap, provider_id: &str, ) -> Vec { let mut ordered = models.values().collect::>(); ordered.sort_by(|a, b| match (&a.release_date, &b.release_date) { (Some(a_date), Some(b_date)) => b_date.cmp(a_date), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => a.name.cmp(&b.name), }); ordered .into_iter() .map(|m| { let reasoning_options = m .variants .as_ref() .map(|variants| ReasoningOption::from_names(variants.keys().cloned())) .unwrap_or_default(); ModelInfo { id: m.id.clone(), name: m.name.clone(), provider_id: Some(provider_id.to_string()), reasoning_options, } }) .collect() } } fn map_opencode_agents(agents: &[SDKAgentInfo]) -> Vec { let default_agent_name = if agents .iter() .any(|a| a.name.eq_ignore_ascii_case("sisyphus")) { "sisyphus" } else { "build" }; agents .iter() .map(|agent| AgentInfo { id: agent.name.clone(), label: agent.name.to_case(Case::Title), description: agent.description.clone(), is_default: agent.name.eq_ignore_ascii_case(default_agent_name), }) .collect() } fn format_tail(captured: Vec) -> String { captured .into_iter() .rev() .take(12) .collect::>() .into_iter() .rev() .collect::>() .join("\n") } async fn wait_for_server_url( stdout: tokio::process::ChildStdout, log_writer: Option, ) -> Result { let mut lines = tokio::io::BufReader::new(stdout).lines(); let deadline = tokio::time::Instant::now() + Duration::from_secs(180); let mut captured: Vec = Vec::new(); loop { if tokio::time::Instant::now() > deadline { return Err(ExecutorError::Io(std::io::Error::other(format!( "Timed out waiting for OpenCode server to print listening URL.\nServer output tail:\n{}", format_tail(captured) )))); } let line = match tokio::time::timeout_at(deadline, lines.next_line()).await { Ok(Ok(Some(line))) => line, Ok(Ok(None)) => { return Err(ExecutorError::Io(std::io::Error::other(format!( "OpenCode server exited before printing listening URL.\nServer output tail:\n{}", format_tail(captured) )))); } Ok(Err(err)) => return Err(ExecutorError::Io(err)), Err(_) => continue, }; if let Some(log_writer) = &log_writer { log_writer .log_event(&OpencodeExecutorEvent::StartupLog { message: line.clone(), }) .await?; } if captured.len() < 64 { captured.push(line.clone()); } if let Some(url) = line.trim().strip_prefix("opencode server listening on ") { // Keep draining stdout to avoid backpressure on the server, but don't block startup. tokio::spawn(async move { let mut lines = tokio::io::BufReader::new(lines.into_inner()).lines(); while let Ok(Some(_)) = lines.next_line().await {} }); return Ok(url.trim().to_string()); } } } fn default_discovered_options() -> crate::executor_discovery::ExecutorDiscoveredOptions { use crate::{ executor_discovery::ExecutorDiscoveredOptions, model_selector::ModelSelectorConfig, }; ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { providers: vec![], models: vec![], default_model: None, agents: vec![], permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised], }, slash_commands: hardcoded_slash_commands(), loading_models: false, loading_agents: false, loading_slash_commands: false, error: None, } } #[async_trait] impl StandardCodingAgentExecutor for Opencode { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = &executor_config.model_id { self.model = Some(model_id.clone()); } if let Some(agent_id) = &executor_config.agent_id { self.agent = Some(agent_id.clone()); } if let Some(permission_policy) = executor_config.permission_policy.clone() { self.auto_approve = matches!(permission_policy, PermissionPolicy::Auto); } if let Some(reasoning_id) = &executor_config.reasoning_id { self.variant = Some(reasoning_id.clone()); } } fn use_approvals(&mut self, approvals: Arc) { self.approvals = Some(approvals); } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let env = setup_permissions_env(self.auto_approve, env); let env = setup_compaction_env(self.auto_compact, &env); self.spawn_inner(current_dir, prompt, None, &env).await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let env = setup_permissions_env(self.auto_approve, env); let env = setup_compaction_env(self.auto_compact, &env); self.spawn_inner(current_dir, prompt, Some(session_id), &env) .await } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { normalize_logs::normalize_logs(msg_store, worktree_path) } fn default_mcp_config_path(&self) -> Option { #[cfg(not(windows))] { let base_dirs = xdg::BaseDirectories::with_prefix("opencode"); // First try opencode.json, then opencode.jsonc base_dirs .get_config_file("opencode.json") .filter(|p| p.exists()) .or_else(|| base_dirs.get_config_file("opencode.jsonc")) } #[cfg(windows)] { let config_dir = std::env::var("XDG_CONFIG_HOME") .map(std::path::PathBuf::from) .ok() .or_else(|| dirs::home_dir().map(|p| p.join(".config"))) .map(|p| p.join("opencode"))?; let path = Some(config_dir.join("opencode.json")) .filter(|p| p.exists()) .unwrap_or_else(|| config_dir.join("opencode.jsonc")); Some(path) } } fn get_availability_info(&self) -> AvailabilityInfo { let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); // Check multiple installation indicator paths: // 1. XDG config dir: $XDG_CONFIG_HOME/opencode // 2. XDG data dir: $XDG_DATA_HOME/opencode // 3. XDG state dir: $XDG_STATE_HOME/opencode // 4. OpenCode CLI home: ~/.opencode #[cfg(not(windows))] let installation_indicator_found = { let base_dirs = xdg::BaseDirectories::with_prefix("opencode"); let config_dir_exists = base_dirs .get_config_home() .map(|config| config.exists()) .unwrap_or(false); let data_dir_exists = base_dirs .get_data_home() .map(|data| data.exists()) .unwrap_or(false); let state_dir_exists = base_dirs .get_state_home() .map(|state| state.exists()) .unwrap_or(false); config_dir_exists || data_dir_exists || state_dir_exists }; #[cfg(windows)] let installation_indicator_found = std::env::var("XDG_CONFIG_HOME") .ok() .map(std::path::PathBuf::from) .and_then(|p| p.join("opencode").exists().then_some(())) .or_else(|| { dirs::home_dir() .and_then(|p| p.join(".config").join("opencode").exists().then_some(())) }) .is_some(); let home_opencode_exists = dirs::home_dir() .map(|home| home.join(".opencode").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found || home_opencode_exists { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } async fn discover_options( &self, workdir: Option<&Path>, repo_path: Option<&Path>, ) -> Result, ExecutorError> { use crate::{ executor_discovery::ExecutorConfigCacheKey, executors::utils::executor_options_cache, }; let cache = executor_options_cache(); let cmd_key = self.compute_models_cache_key(); let base_executor = BaseCodingAgent::Opencode; let (target_path, initial_options) = if let Some(wd) = workdir { let wd_buf = wd.to_path_buf(); let target_key = ExecutorConfigCacheKey::new(Some(&wd_buf), cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&target_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } let provisional = repo_path .and_then(|rp| { let rp_buf = rp.to_path_buf(); let repo_key = ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor); cache.get(&repo_key) }) .or_else(|| { let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); cache.get(&global_key) }); ( Some(wd.to_path_buf()), provisional .map(|p| p.as_ref().clone().with_loading(true)) .unwrap_or_else(|| default_discovered_options().with_loading(true)), ) } else if let Some(rp) = repo_path { let rp_buf = rp.to_path_buf(); let target_key = ExecutorConfigCacheKey::new(Some(&rp_buf), cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&target_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); let provisional = cache.get(&global_key); ( Some(rp.to_path_buf()), provisional .map(|p| p.as_ref().clone().with_loading(true)) .unwrap_or_else(|| default_discovered_options().with_loading(true)), ) } else { let global_key = ExecutorConfigCacheKey::new(None, cmd_key.clone(), base_executor); if let Some(cached) = cache.get(&global_key) { return Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(cached.as_ref().clone().with_loading(false)) }))); } (None, default_discovered_options().with_loading(true)) }; let initial_patch = patch::executor_discovered_options(initial_options); let this = self.clone(); let cmd_key_for_discovery = cmd_key.clone(); let discovery_stream = async_stream::stream! { let discovery_path = target_path.as_deref().unwrap_or(Path::new(".")).to_path_buf(); let mut final_options = default_discovered_options(); let env = ExecutionEnv::new(RepoContext::default(), false, String::new()); let env = setup_permissions_env(this.auto_approve, &env); let server = match this.spawn_server(&discovery_path, &env).await { Ok(s) => s, Err(e) => { tracing::warn!("Failed to spawn OpenCode server: {}", e); yield patch::discovery_error(e.to_string()); return; } }; let directory = discovery_path.to_string_lossy(); let client = match build_authenticated_client(&directory, &server.server_password) { Ok(c) => c, Err(e) => { tracing::warn!("Failed to build authenticated client: {}", e); yield patch::discovery_error(e.to_string()); return; } }; let base_url = server.base_url.clone(); let directory_str = directory.to_string(); let providers_future = list_providers(&client, &base_url, &directory_str); let agents_future = list_agents(&client, &base_url, &directory_str); let commands_future = list_commands(&client, &base_url, &directory_str); let config_future = async { let resp = client .get(format!("{}/config", base_url)) .query(&[("directory", &directory_str)]) .send() .await .map_err(|e| ExecutorError::Io(std::io::Error::other(format!("HTTP request failed: {e}"))))?; if resp.status().is_success() { resp.json::().await.map_err(|e| { ExecutorError::Io(std::io::Error::other(format!( "Failed to parse config response: {e}" ))) }) } else { Ok(Config { model: None }) } }; let (providers_result, agents_result, commands_result, config_result) = tokio::join!(providers_future, agents_future, commands_future, config_future); match providers_result { Ok(data) => { models::seed_context_windows_cache( &cmd_key_for_discovery, models::extract_context_windows(&data), ); final_options.model_selector.providers = data .all .iter() .filter(|p| data.connected.contains(&p.id)) .map(|p| ModelProvider { id: p.id.clone(), name: p.name.clone(), }) .collect(); final_options.model_selector.models = data .all .iter() .filter(|p| data.connected.contains(&p.id)) .flat_map(|p| this.transform_models(&p.models, &p.id)) .collect(); yield patch::update_providers(final_options.model_selector.providers.clone()); yield patch::update_models(final_options.model_selector.models.clone()); yield patch::models_loaded(); } Err(e) => { tracing::warn!("Failed to fetch OpenCode providers: {}", e); } } match config_result { Ok(config) => { final_options.model_selector.default_model = config.model; yield patch::update_default_model(final_options.model_selector.default_model.clone()); } Err(e) => { tracing::warn!("Failed to fetch OpenCode config: {}", e); } } match agents_result { Ok(agents) => { final_options.model_selector.agents = map_opencode_agents(&agents); yield patch::update_agents(final_options.model_selector.agents.clone()); yield patch::agents_loaded(); } Err(e) => { tracing::warn!("Failed to fetch OpenCode agents: {}", e); } } match commands_result { Ok(commands) => { let defaults = hardcoded_slash_commands(); let mut seen: std::collections::HashSet = defaults.iter().map(|cmd| cmd.name.clone()).collect(); let discovered: Vec = commands .into_iter() .map(|cmd| SlashCommandDescription { name: cmd.name.trim_start_matches('/').to_string(), description: cmd.description, }) .filter(|cmd| seen.insert(cmd.name.clone())) .chain(defaults) .collect(); final_options.slash_commands = reorder_slash_commands(discovered); yield patch::update_slash_commands(final_options.slash_commands.clone()); yield patch::slash_commands_loaded(); } Err(e) => { tracing::warn!("Failed to fetch OpenCode commands: {}", e); final_options.slash_commands = hardcoded_slash_commands(); yield patch::update_slash_commands(final_options.slash_commands.clone()); yield patch::slash_commands_loaded(); } } let cache = executor_options_cache(); if let Some(path) = &target_path { let target_cache_key = ExecutorConfigCacheKey::new( Some(path), cmd_key_for_discovery.clone(), BaseCodingAgent::Opencode, ); cache.put(target_cache_key, final_options.clone()); } let global_cache_key = ExecutorConfigCacheKey::new( None, cmd_key_for_discovery, BaseCodingAgent::Opencode, ); cache.put(global_cache_key, final_options); }; Ok(Box::pin( futures::stream::once(async move { initial_patch }).chain(discovery_stream), )) } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::Opencode, variant: None, model_id: self.model.clone(), agent_id: self.agent.clone(), reasoning_id: self.variant.clone(), permission_policy: Some(if self.auto_approve { PermissionPolicy::Auto } else { PermissionPolicy::Supervised }), } } } fn default_to_true() -> bool { true } fn setup_permissions_env(auto_approve: bool, env: &ExecutionEnv) -> ExecutionEnv { let mut env = env.clone(); let permissions = match env.get("OPENCODE_PERMISSION") { Some(existing) => Some(existing.to_string()), None => build_default_permissions(auto_approve), }; if let Some(permissions) = permissions { env.insert("OPENCODE_PERMISSION", &permissions); } env } fn build_default_permissions(auto_approve: bool) -> Option { if auto_approve { None } else { Some(r#"{"edit":"ask","bash":"ask","webfetch":"ask","doom_loop":"ask","external_directory":"ask","question":"allow"}"#.to_string()) } } fn setup_compaction_env(auto_compact: bool, env: &ExecutionEnv) -> ExecutionEnv { if !auto_compact { return env.clone(); } let mut env = env.clone(); let merged = merge_compaction_config(env.get("OPENCODE_CONFIG_CONTENT").map(String::as_str)); env.insert("OPENCODE_CONFIG_CONTENT", merged); env } fn merge_compaction_config(existing_json: Option<&str>) -> String { let mut config: Map = existing_json .and_then(|value| serde_json::from_str(value.trim()).ok()) .unwrap_or_default(); let mut compaction = config .remove("compaction") .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); compaction.insert("auto".to_string(), Value::Bool(true)); config.insert("compaction".to_string(), Value::Object(compaction)); serde_json::to_string(&config).unwrap_or_else(|_| r#"{"compaction":{"auto":true}}"#.to_string()) } ================================================ FILE: crates/executors/src/executors/qa_mock.rs ================================================ //! QA Mode: Mock executor for testing //! //! This module provides a mock executor that: //! 1. Performs random file operations (create, delete, modify) //! 2. Streams 10 mock log entries over 10 seconds //! 3. Outputs logs in ClaudeJson format for compatibility with existing log normalization use std::{path::Path, process::Stdio, sync::Arc}; use async_trait::async_trait; use rand::seq::SliceRandom as _; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use ts_rs::TS; use workspace_utils::{command_ext::GroupSpawnNoWindowExt, msg_store::MsgStore}; use crate::{ env::ExecutionEnv, executors::{ BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, claude::{ ClaudeContentItem, ClaudeJson, ClaudeMessage, ClaudeMessageContent, ClaudeToolData, }, }, logs::utils::EntryIndexProvider, profile::ExecutorConfig, }; /// Mock executor for QA testing #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, TS, JsonSchema)] pub struct QaMockExecutor; #[async_trait] impl StandardCodingAgentExecutor for QaMockExecutor { fn apply_overrides(&mut self, _executor_config: &ExecutorConfig) {} async fn spawn( &self, current_dir: &Path, prompt: &str, _env: &ExecutionEnv, ) -> Result { info!("QA Mock Executor: spawning mock execution"); // 1. Perform file operations before spawning the log output process perform_file_operations(current_dir).await; // 2. Generate mock logs and write to temp file to avoid shell escaping issues let logs = generate_mock_logs(prompt); let temp_dir = std::env::temp_dir(); let log_file = temp_dir.join(format!("qa_mock_logs_{}.jsonl", uuid::Uuid::new_v4())); // Write all logs to file, one per line let content = logs.join("\n") + "\n"; tokio::fs::write(&log_file, &content) .await .map_err(|e| ExecutorError::Io(std::io::Error::other(e)))?; // 3. Create shell script that reads file and outputs with delays // Using IFS= read -r to preserve exact content (no word splitting, no backslash interpretation) let script = format!( r#"while IFS= read -r line; do echo "$line"; sleep 1; done < "{}"; rm -f "{}""#, log_file.display(), log_file.display() ); let mut cmd = tokio::process::Command::new("sh"); cmd.arg("-c") .arg(&script) .current_dir(current_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let child = cmd.group_spawn_no_window().map_err(ExecutorError::Io)?; Ok(SpawnedChild::from(child)) } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, _session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { // QA mode doesn't support real sessions, just spawn fresh info!("QA Mock Executor: follow-up request treated as new spawn"); self.spawn(current_dir, prompt, env).await } fn normalize_logs( &self, msg_store: Arc, current_dir: &Path, ) -> Vec> { // Reuse Claude's log processor since we output ClaudeJson format let entry_index_provider = EntryIndexProvider::start_from(&msg_store); let h1 = crate::executors::claude::ClaudeLogProcessor::process_logs( msg_store, current_dir, entry_index_provider, crate::executors::claude::HistoryStrategy::Default, ); vec![h1] } fn default_mcp_config_path(&self) -> Option { None // QA mock doesn't need MCP config } fn get_preset_options(&self) -> ExecutorConfig { ExecutorConfig { executor: BaseCodingAgent::QaMock, variant: None, model_id: Some("qa-mock".to_string()), agent_id: None, reasoning_id: None, permission_policy: Some(crate::model_selector::PermissionPolicy::Auto), } } } /// Perform random file operations in the worktree async fn perform_file_operations(dir: &Path) { info!("QA Mock: performing file operations in {:?}", dir); // Create: qa_created_{uuid}.txt let uuid = uuid::Uuid::new_v4(); let new_file = dir.join(format!("qa_created_{}.txt", uuid)); match tokio::fs::write(&new_file, "QA mode created this file\n").await { Ok(_) => info!("QA Mock: created file {:?}", new_file), Err(e) => warn!("QA Mock: failed to create file: {}", e), } // Find files (excluding .git and binary files) let files: Vec<_> = walkdir::WalkDir::new(dir) .max_depth(3) // Limit depth to avoid long walks .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) .filter(|e| !e.path().to_string_lossy().contains(".git")) .filter(|e| { e.path() .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ["rs", "ts", "js", "txt", "md", "json"].contains(&ext)) }) .collect(); if files.len() >= 2 { // Pick random indices before any await points (thread_rng is not Send) let (remove_idx, modify_idx) = { let mut rng = rand::thread_rng(); let mut indices: Vec = (0..files.len()).collect(); indices.shuffle(&mut rng); (indices.first().copied(), indices.get(1).copied()) }; // Remove a random file (first shuffled index) if let Some(idx) = remove_idx { let file_to_remove = files[idx].path().to_path_buf(); // Don't remove the file we just created if file_to_remove != new_file { match tokio::fs::remove_file(&file_to_remove).await { Ok(_) => info!("QA Mock: removed file {:?}", file_to_remove), Err(e) => warn!("QA Mock: failed to remove file: {}", e), } } } // Modify a different random file (second shuffled index) if let Some(idx) = modify_idx { let file_to_modify = files[idx].path().to_path_buf(); // Don't modify the file we just created if file_to_modify != new_file { match tokio::fs::read_to_string(&file_to_modify).await { Ok(content) => { let modified = format!( "{}\n// QA modification at {}\n", content, chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") ); match tokio::fs::write(&file_to_modify, modified).await { Ok(_) => info!("QA Mock: modified file {:?}", file_to_modify), Err(e) => warn!("QA Mock: failed to write modified file: {}", e), } } Err(e) => warn!("QA Mock: failed to read file for modification: {}", e), } } } } else { info!( "QA Mock: not enough files found for remove/modify operations (found {})", files.len() ); } } /// Generate 10 mock log entries in ClaudeJson format using strongly-typed structs fn generate_mock_logs(prompt: &str) -> Vec { let session_id = uuid::Uuid::new_v4().to_string(); let logs: Vec = vec![ // 1. System init ClaudeJson::System { subtype: Some("init".to_string()), session_id: Some(session_id.clone()), cwd: None, tools: None, model: Some("qa-mock-executor".to_string()), api_key_source: Some("unknown".to_string()), status: None, slash_commands: vec![], plugins: vec![], agents: vec![], task_id: None, tool_use_id: None, description: None, task_type: None, prompt: None, summary: None, last_tool_name: None, }, // 2. Assistant thinking ClaudeJson::Assistant { message: ClaudeMessage { id: Some("msg-qa-1".to_string()), message_type: Some("message".to_string()), role: "assistant".to_string(), model: Some("qa-mock".to_string()), content: ClaudeMessageContent::Array(vec![ClaudeContentItem::Thinking { thinking: "Analyzing the QA task and preparing mock execution...".to_string(), }]), stop_reason: None, }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-1".to_string()), }, // 3. Read tool use ClaudeJson::Assistant { message: ClaudeMessage { id: Some("msg-qa-2".to_string()), message_type: Some("message".to_string()), role: "assistant".to_string(), model: Some("qa-mock".to_string()), content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse { id: "qa-tool-1".to_string(), tool_data: ClaudeToolData::Read { file_path: "README.md".to_string(), }, }]), stop_reason: None, }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-2".to_string()), }, // 4. Read tool result ClaudeJson::User { message: ClaudeMessage { id: Some("msg-qa-3".to_string()), message_type: Some("message".to_string()), role: "user".to_string(), model: None, content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult { tool_use_id: "qa-tool-1".to_string(), content: serde_json::json!( "# Project README\\n\\nThis is a QA test repository." ), is_error: Some(false), }]), stop_reason: None, }, is_synthetic: false, is_replay: false, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-3".to_string()), }, // 5. Write tool use ClaudeJson::Assistant { message: ClaudeMessage { id: Some("msg-qa-4".to_string()), message_type: Some("message".to_string()), role: "assistant".to_string(), model: Some("qa-mock".to_string()), content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse { id: "qa-tool-2".to_string(), tool_data: ClaudeToolData::Write { file_path: "qa_output.txt".to_string(), content: "QA generated content".to_string(), }, }]), stop_reason: None, }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-4".to_string()), }, // 6. Write tool result ClaudeJson::User { message: ClaudeMessage { id: Some("msg-qa-5".to_string()), message_type: Some("message".to_string()), role: "user".to_string(), model: None, content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult { tool_use_id: "qa-tool-2".to_string(), content: serde_json::json!("File written successfully"), is_error: Some(false), }]), stop_reason: None, }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-5".to_string()), is_synthetic: false, is_replay: false, }, // 7. Bash tool use ClaudeJson::Assistant { message: ClaudeMessage { id: Some("msg-qa-6".to_string()), message_type: Some("message".to_string()), role: "assistant".to_string(), model: Some("qa-mock".to_string()), content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolUse { id: "qa-tool-3".to_string(), tool_data: ClaudeToolData::Bash { command: "echo 'QA test complete'".to_string(), description: Some("Run QA test command".to_string()), }, }]), stop_reason: None, }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-6".to_string()), }, // 8. Bash tool result ClaudeJson::User { message: ClaudeMessage { id: Some("msg-qa-7".to_string()), message_type: Some("message".to_string()), role: "user".to_string(), model: None, content: ClaudeMessageContent::Array(vec![ClaudeContentItem::ToolResult { tool_use_id: "qa-tool-3".to_string(), content: serde_json::json!("QA test complete\\n"), is_error: Some(false), }]), stop_reason: None, }, is_synthetic: false, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-7".to_string()), is_replay: false, }, // 9. Assistant final message ClaudeJson::Assistant { message: ClaudeMessage { id: Some("msg-qa-8".to_string()), message_type: Some("message".to_string()), role: "assistant".to_string(), model: Some("qa-mock".to_string()), content: ClaudeMessageContent::Array(vec![ClaudeContentItem::Text { text: format!( "QA mode execution completed successfully.\\n\\nI performed the following operations:\\n1. Read README.md\\n2. Created qa_output.txt\\n3. Ran a test command\\nOriginal prompt: {}", prompt ), }]), stop_reason: Some("end_turn".to_string()), }, session_id: Some(session_id.clone()), uuid: Some("uuid-qa-8".to_string()), }, // 10. Result success ClaudeJson::Result { subtype: Some("success".to_string()), is_error: Some(false), duration_ms: Some(10000), result: None, error: None, num_turns: Some(3), session_id: Some(session_id), model_usage: None, usage: None, }, ]; // Serialize to JSON strings - this ensures proper escaping logs.into_iter() .map(|log| serde_json::to_string(&log).expect("ClaudeJson should serialize")) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_mock_logs_count() { let logs = generate_mock_logs("test prompt"); assert_eq!(logs.len(), 10, "Should generate exactly 10 log entries"); } #[test] fn test_generate_mock_logs_valid_json() { let logs = generate_mock_logs("test prompt"); for (i, log) in logs.iter().enumerate() { let parsed: Result = serde_json::from_str(log); assert!( parsed.is_ok(), "Log entry {} should be valid JSON: {}", i, log ); } } #[test] fn test_generate_mock_logs_deserializes_to_claudejson() { let logs = generate_mock_logs("test prompt"); for (i, log) in logs.iter().enumerate() { let parsed: Result = serde_json::from_str(log); assert!( parsed.is_ok(), "Log entry {} should deserialize to ClaudeJson: {} - error: {:?}", i, log, parsed.err() ); } } #[test] fn test_escape_special_characters() { let logs = generate_mock_logs("test with \"quotes\" and\nnewlines"); // The final assistant message (index 8) should contain the prompt let final_log = &logs[8]; let parsed: ClaudeJson = serde_json::from_str(final_log).unwrap(); if let ClaudeJson::Assistant { message, .. } = parsed { if let ClaudeMessageContent::Array(items) = &message.content { if let Some(ClaudeContentItem::Text { text }) = items.first() { assert!(text.contains("test with \"quotes\" and\nnewlines")); } else { panic!("Expected Text content item"); } } else { panic!("Expected Array content"); } } else { panic!("Expected Assistant variant"); } } } ================================================ FILE: crates/executors/src/executors/qwen.rs ================================================ use std::{path::Path, sync::Arc}; use async_trait::async_trait; use derivative::Derivative; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use workspace_utils::msg_store::MsgStore; use crate::{ approvals::ExecutorApprovalService, command::{CmdOverrides, CommandBuildError, CommandBuilder, apply_overrides}, env::ExecutionEnv, executor_discovery::ExecutorDiscoveredOptions, executors::{ AppendPrompt, AvailabilityInfo, BaseCodingAgent, ExecutorError, SpawnedChild, StandardCodingAgentExecutor, gemini::AcpAgentHarness, }, logs::utils::patch, model_selector::{ModelSelectorConfig, PermissionPolicy}, profile::ExecutorConfig, }; #[derive(Derivative, Clone, Serialize, Deserialize, TS, JsonSchema)] #[derivative(Debug, PartialEq)] pub struct QwenCode { #[serde(default)] pub append_prompt: AppendPrompt, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "mode")] pub agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub yolo: Option, #[serde(flatten)] pub cmd: CmdOverrides, #[serde(skip)] #[ts(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub approvals: Option>, } impl QwenCode { fn build_command_builder(&self) -> Result { let mut builder = CommandBuilder::new("npx -y @qwen-code/qwen-code@0.9.1"); if let Some(model) = &self.model { builder = builder.extend_params(["--model", model.as_str()]); } if self.yolo.unwrap_or(false) { builder = builder.extend_params(["--yolo"]); } builder = builder.extend_params(["--acp"]); apply_overrides(builder, &self.cmd) } } #[async_trait] impl StandardCodingAgentExecutor for QwenCode { fn apply_overrides(&mut self, executor_config: &ExecutorConfig) { if let Some(model_id) = executor_config.model_id.as_ref() { self.model = Some(model_id.clone()); } if let Some(agent_id) = executor_config.agent_id.as_ref() { self.agent = Some(agent_id.clone()); } if let Some(permission_policy) = executor_config.permission_policy.clone() { self.yolo = Some(matches!( permission_policy, crate::model_selector::PermissionPolicy::Auto )); } } fn use_approvals(&mut self, approvals: Arc) { self.approvals = Some(approvals); } async fn spawn( &self, current_dir: &Path, prompt: &str, env: &ExecutionEnv, ) -> Result { let qwen_command = self.build_command_builder()?.build_initial()?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut harness = AcpAgentHarness::with_session_namespace("qwen_sessions"); if let Some(model) = &self.model { harness = harness.with_model(model); } if let Some(agent) = &self.agent { harness = harness.with_mode(agent); } let approvals = if self.yolo.unwrap_or(false) { None } else { self.approvals.clone() }; harness .spawn_with_command( current_dir, combined_prompt, qwen_command, env, &self.cmd, approvals, ) .await } async fn spawn_follow_up( &self, current_dir: &Path, prompt: &str, session_id: &str, _reset_to_message_id: Option<&str>, env: &ExecutionEnv, ) -> Result { let qwen_command = self.build_command_builder()?.build_follow_up(&[])?; let combined_prompt = self.append_prompt.combine_prompt(prompt); let mut harness = AcpAgentHarness::with_session_namespace("qwen_sessions"); if let Some(model) = &self.model { harness = harness.with_model(model); } if let Some(agent) = &self.agent { harness = harness.with_mode(agent); } let approvals = if self.yolo.unwrap_or(false) { None } else { self.approvals.clone() }; harness .spawn_follow_up_with_command( current_dir, combined_prompt, session_id, qwen_command, env, &self.cmd, approvals, ) .await } fn normalize_logs( &self, msg_store: Arc, worktree_path: &Path, ) -> Vec> { crate::executors::acp::normalize_logs(msg_store, worktree_path) } // MCP configuration methods fn default_mcp_config_path(&self) -> Option { dirs::home_dir().map(|home| home.join(".qwen").join("settings.json")) } fn get_availability_info(&self) -> AvailabilityInfo { let mcp_config_found = self .default_mcp_config_path() .map(|p| p.exists()) .unwrap_or(false); let installation_indicator_found = dirs::home_dir() .map(|home| home.join(".qwen").join("installation_id").exists()) .unwrap_or(false); if mcp_config_found || installation_indicator_found { AvailabilityInfo::InstallationFound } else { AvailabilityInfo::NotFound } } fn get_preset_options(&self) -> ExecutorConfig { use crate::model_selector::*; ExecutorConfig { executor: BaseCodingAgent::QwenCode, variant: None, model_id: self.model.clone(), agent_id: self.agent.clone(), reasoning_id: None, permission_policy: Some(if self.yolo.unwrap_or(false) { PermissionPolicy::Auto } else { PermissionPolicy::Supervised }), } } async fn discover_options( &self, _workdir: Option<&std::path::Path>, _repo_path: Option<&std::path::Path>, ) -> Result, ExecutorError> { let options = ExecutorDiscoveredOptions { model_selector: ModelSelectorConfig { permissions: vec![PermissionPolicy::Auto, PermissionPolicy::Supervised], ..Default::default() }, ..Default::default() }; Ok(Box::pin(futures::stream::once(async move { patch::executor_discovered_options(options) }))) } } ================================================ FILE: crates/executors/src/executors/utils.rs ================================================ use std::{ hash::Hash, num::NonZeroUsize, sync::{Arc, Mutex, OnceLock}, time::{Duration, Instant}, }; use futures::StreamExt; use lru::LruCache; use super::{BaseCodingAgent, SlashCommandDescription, StandardCodingAgentExecutor}; use crate::{ executor_discovery::{ExecutorConfigCacheKey, ExecutorDiscoveredOptions}, profile::ExecutorConfigs, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashCommandCall<'a> { /// The command name in lowercase (without the leading slash) pub name: String, /// The arguments after the command name pub arguments: &'a str, } pub fn parse_slash_command<'a, T>(prompt: &'a str) -> Option where T: From>, { let trimmed = prompt.trim_start(); let without_slash = trimmed.strip_prefix('/')?; let mut parts = without_slash.splitn(2, |ch: char| ch.is_whitespace()); let name = parts.next()?.trim().to_lowercase(); if name.is_empty() { return None; } let arguments = parts.next().map(|s| s.trim()).unwrap_or(""); Some(T::from(SlashCommandCall { name, arguments })) } /// Reorder slash commands to prioritize compact then review. #[must_use] pub fn reorder_slash_commands( commands: impl IntoIterator, ) -> Vec { let mut compact_command = None; let mut review_commands = None; let mut remaining_commands = Vec::new(); for command in commands { match command.name.as_str() { "compact" => compact_command = Some(command), "review" => review_commands = Some(command), _ => remaining_commands.push(command), } } compact_command .into_iter() .chain(review_commands) .chain(remaining_commands) .collect() } #[derive(Clone, Debug)] struct CacheEntry { cached_at: Instant, value: Arc, } pub struct TtlCache { cache: Mutex>>, ttl: Duration, } impl TtlCache where K: Hash + Eq, { pub fn new(capacity: usize, ttl: Duration) -> Self { Self { cache: Mutex::new(LruCache::new( NonZeroUsize::new(capacity).unwrap_or_else(|| NonZeroUsize::new(1).unwrap()), )), ttl, } } #[must_use] pub fn get(&self, key: &K) -> Option> { let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let entry = cache.get(key)?; let value = entry.value.clone(); let expired = entry.cached_at.elapsed() > self.ttl; if expired { cache.pop(key); None } else { Some(value) } } pub fn put(&self, key: K, value: V) { let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); cache.put( key, CacheEntry { cached_at: Instant::now(), value: Arc::new(value), }, ); } } pub const EXECUTOR_OPTIONS_CACHE_CAPACITY: usize = 64; pub const DEFAULT_CACHE_TTL: Duration = Duration::from_mins(5); pub fn executor_options_cache() -> &'static TtlCache { static INSTANCE: OnceLock> = OnceLock::new(); INSTANCE.get_or_init(|| TtlCache::new(EXECUTOR_OPTIONS_CACHE_CAPACITY, DEFAULT_CACHE_TTL)) } /// Spawn a background task to refresh the global cache for an executor. /// This should be called on every use to keep the cache warm. pub fn spawn_global_cache_refresh_for_agent(base_agent: BaseCodingAgent) { spawn_global_cache_refresh_for_agent_with_configs(base_agent, ExecutorConfigs::get_cached()); } fn spawn_global_cache_refresh_for_agent_with_configs( base_agent: BaseCodingAgent, configs: ExecutorConfigs, ) { let profile_id = crate::profile::ExecutorProfileId::new(base_agent); if let Some(coding_agent) = configs.get_coding_agent(&profile_id) { tokio::spawn(async move { if let Ok(mut stream) = coding_agent.discover_options(None, None).await { while stream.next().await.is_some() {} } }); } } /// Preload the global cache for all executors with DEFAULT presets. /// This should be called on startup to warm the cache. pub async fn preload_global_executor_options_cache() { let configs = ExecutorConfigs::get_cached(); let executors: Vec = configs.executors.keys().copied().collect(); for base_agent in executors { spawn_global_cache_refresh_for_agent_with_configs(base_agent, configs.clone()); } } ================================================ FILE: crates/executors/src/lib.rs ================================================ pub mod actions; pub mod approvals; pub mod command; pub mod env; pub mod executor_discovery; pub mod executors; pub mod logs; pub mod mcp_config; pub mod model_selector; pub mod profile; pub mod stdout_dup; ================================================ FILE: crates/executors/src/logs/mod.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use workspace_utils::approvals::{ApprovalStatus, QuestionStatus}; use crate::logs::utils::shell_command_parsing::CommandCategory; pub mod plain_text_processor; pub mod stderr_processor; pub mod utils; #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolResultValueType { Markdown, Json, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ToolResult { pub r#type: ToolResultValueType, /// For Markdown, this will be a JSON string; for JSON, a structured value pub value: serde_json::Value, } impl ToolResult { pub fn markdown>(markdown: S) -> Self { Self { r#type: ToolResultValueType::Markdown, value: serde_json::Value::String(markdown.into()), } } pub fn json(value: serde_json::Value) -> Self { Self { r#type: ToolResultValueType::Json, value, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CommandExitStatus { ExitCode { code: i32 }, Success { success: bool }, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CommandRunResult { pub exit_status: Option, pub output: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct NormalizedConversation { pub entries: Vec, pub session_id: Option, pub executor_type: String, pub prompt: Option, pub summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum NormalizedEntryError { SetupRequired, Other, } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum NormalizedEntryType { UserMessage, UserFeedback { denied_tool: String, }, AssistantMessage, ToolUse { tool_name: String, action_type: ActionType, status: ToolStatus, }, SystemMessage, ErrorMessage { error_type: NormalizedEntryError, }, Thinking, Loading, NextAction { failed: bool, execution_processes: usize, needs_setup: bool, }, TokenUsageInfo(TokenUsageInfo), UserAnsweredQuestions { answers: Vec, }, } /// A question–answer pair from a completed AskUserQuestion interaction. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AnsweredQuestion { pub question: String, pub answer: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct TokenUsageInfo { pub total_tokens: u32, pub model_context_window: u32, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct NormalizedEntry { pub timestamp: Option, pub entry_type: NormalizedEntryType, pub content: String, #[ts(skip)] pub metadata: Option, } impl NormalizedEntry { pub fn with_tool_status(&self, status: ToolStatus) -> Option { if let NormalizedEntryType::ToolUse { tool_name, action_type, .. } = &self.entry_type { Some(Self { entry_type: NormalizedEntryType::ToolUse { tool_name: tool_name.clone(), action_type: action_type.clone(), status, }, ..self.clone() }) } else { None } } } #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] #[serde(tag = "status", rename_all = "snake_case")] pub enum ToolStatus { #[default] Created, Success, Failed, Denied { reason: Option, }, PendingApproval { approval_id: String, }, TimedOut, } impl ToolStatus { pub fn from_approval_status(status: &ApprovalStatus) -> Option { match status { ApprovalStatus::Approved => Some(ToolStatus::Created), ApprovalStatus::Denied { reason } => Some(ToolStatus::Denied { reason: reason.clone(), }), ApprovalStatus::TimedOut => Some(ToolStatus::TimedOut), ApprovalStatus::Pending => None, } } pub fn from_question_status(status: &QuestionStatus) -> Self { match status { QuestionStatus::Answered { .. } => ToolStatus::Success, QuestionStatus::TimedOut => ToolStatus::TimedOut, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct TodoItem { pub content: String, pub status: String, #[serde(default)] pub priority: Option, } /// Types of tool actions that can be performed #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ActionType { FileRead { path: String, }, FileEdit { path: String, changes: Vec, }, CommandRun { command: String, #[serde(default)] result: Option, #[serde(default)] category: CommandCategory, }, Search { query: String, }, WebFetch { url: String, }, /// Generic tool with optional arguments and result for rich rendering Tool { tool_name: String, #[serde(default)] arguments: Option, #[serde(default)] result: Option, }, TaskCreate { description: String, #[serde(default)] subagent_type: Option, #[serde(default)] result: Option, }, PlanPresentation { plan: String, }, TodoManagement { todos: Vec, operation: String, }, AskUserQuestion { questions: Vec, }, Other { description: String, }, } /// A single question in an AskUserQuestion tool call. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AskUserQuestionItem { pub question: String, pub header: String, pub options: Vec, #[serde(rename = "multiSelect")] pub multi_select: bool, } /// An option for an AskUserQuestion question. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AskUserQuestionOption { pub label: String, pub description: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "action", rename_all = "snake_case")] pub enum FileChange { /// Create a file if it doesn't exist, and overwrite its content. Write { content: String }, /// Delete a file. Delete, /// Rename a file. Rename { new_path: String }, /// Edit a file with a unified diff. Edit { /// Unified diff containing file header and hunks. unified_diff: String, /// Whether line number in the hunks are reliable. has_line_numbers: bool, }, } ================================================ FILE: crates/executors/src/logs/plain_text_processor.rs ================================================ //! Reusable log processor for plain-text streams with flexible clustering and formatting. //! //! Clusters messages into entries based on configurable size and time-gap heuristics, and supports //! pluggable formatters for transforming or annotating chunks (e.g., inserting line breaks or parsing tool calls). //! //! Capable of handling mixed-format streams, including interleaved tool calls and assistant messages, //! with custom split predicates to detect embedded markers and emit separate entries. //! //! ## Use cases //! - **stderr_processor**: Cluster stderr lines by time gap and format as `ErrorMessage` log entries. //! See [`stderr_processor::normalize_stderr_logs`]. //! - **Gemini executor**: Post-process Gemini CLI output to make it prettier, then format it as assistant messages clustered by size. //! See [`crate::executors::gemini::Gemini::format_stdout_chunk`]. //! - **Tool call support**: detect lines starting with a distinct marker via `message_boundary_predicate` to separate tool invocations. use std::{ time::{Duration, Instant}, vec, }; use bon::bon; use json_patch::Patch; use super::{ NormalizedEntry, utils::{ConversationPatch, EntryIndexProvider}, }; /// Controls message boundary for advanced executors. /// The main use-case is to support mixed-content log streams where tool calls and assistant messages are interleaved. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MessageBoundary { /// Conclude the current message entry at the given line. /// Useful when we detect a message of a different kind than the current one, e.g., when a tool call starts we need to close the current assistant message. Split(usize), /// Request more content. Signals that the current entry is incomplete and should not be emitted yet. /// This should only be the case in tool calls, as assistant messages can be partially emitted. IncompleteContent, } /// Internal buffer for collecting streaming text into individual lines. /// Maintains line and size information for heuristics and processing. #[derive(Debug)] struct PlainTextBuffer { /// All lines including last partial line. Complete lines have trailing \n, partial line doesn't lines: Vec, /// Current buffered length total_len: usize, } impl PlainTextBuffer { /// Create a new empty buffer pub fn new() -> Self { Self { lines: Vec::new(), total_len: 0, } } /// Ingest a new text chunk into the buffer. pub fn ingest(&mut self, text_chunk: String) { debug_assert!(!text_chunk.is_empty()); // Add a new lines or grow the current partial line let current_partial = if self.lines.last().is_some_and(|l| !l.ends_with('\n')) { let partial = self.lines.pop().unwrap(); self.total_len = self.total_len.saturating_sub(partial.len()); partial } else { String::new() }; // Process chunk let combined_text = current_partial + &text_chunk; let size = combined_text.len(); // Append new lines let parts: Vec = combined_text .split_inclusive('\n') .map(ToString::to_string) .collect(); self.lines.extend(parts); self.total_len += size; } /// Remove and return the first `n` buffered lines, pub fn drain_lines(&mut self, n: usize) -> Vec { let n = n.min(self.lines.len()); let drained: Vec = self.lines.drain(..n).collect(); // Update total_bytes for line in &drained { self.total_len = self.total_len.saturating_sub(line.len()); } drained } /// Remove and return lines until the content length is at least `len`. /// Useful for size-based splitting of content. pub fn drain_size(&mut self, len: usize) -> Vec { let mut drained_len = 0; let mut lines_to_drain = 0; for line in &self.lines { if drained_len >= len && lines_to_drain > 0 { break; } drained_len += line.len(); lines_to_drain += 1; } self.drain_lines(lines_to_drain) } /// Empty the buffer, removing and returning all content, pub fn flush(&mut self) -> Vec { let result = self.lines.drain(..).collect(); self.total_len = 0; result } /// Return the total length of content. pub fn total_len(&self) -> usize { self.total_len } /// View lines. pub fn lines(&self) -> &[String] { &self.lines } /// Mutably view lines for in-place transformations. pub fn lines_mut(&mut self) -> &mut Vec { &mut self.lines } /// Recompute cached total length from current lines. pub fn recompute_len(&mut self) { self.total_len = self.lines.iter().map(|s| s.len()).sum(); } /// Get the current partial line. pub fn partial_line(&self) -> Option<&str> { if let Some(last) = self.lines.last() && !last.ends_with('\n') { return Some(last); } None } /// Check if the buffer is empty. pub fn is_empty(&self) -> bool { debug_assert!(self.lines.len() == 0 || self.total_len > 0); self.total_len == 0 } } impl Default for PlainTextBuffer { fn default() -> Self { Self::new() } } /// Optional content formatting function. Can be used post-process raw output before creating normalized entries. pub type FormatChunkFn = Box, String) -> String + Send + 'static>; /// Optional predicate function to determine message boundaries. This enables detecting tool calls interleaved with assistant messages. pub type MessageBoundaryPredicateFn = Box Option + Send + 'static>; /// Function to create a `NormalizedEntry` from content. pub type NormalizedEntryProducerFn = Box NormalizedEntry + Send + 'static>; /// Optional function to transform buffered lines in-place before boundary checks. pub type LinesTransformFn = Box) + Send + 'static>; /// High-level plain text log processor with configurable formatting and splitting pub struct PlainTextLogProcessor { buffer: PlainTextBuffer, index_provider: EntryIndexProvider, entry_size_threshold: Option, time_gap: Option, format_chunk: Option, transform_lines: Option, message_boundary_predicate: Option, normalized_entry_producer: NormalizedEntryProducerFn, last_chunk_arrival_time: Instant, // time since last chunk arrived current_entry_index: Option, } impl PlainTextLogProcessor { /// Process incoming text and return JSON patches for any complete entries pub fn process(&mut self, text_chunk: String) -> Vec { if text_chunk.is_empty() { return vec![]; } if !self.buffer.is_empty() { // If the new content arrived after the (**Optional**) time threshold between messages, we consider it a new entry. // Useful for stderr streams where we want to group related lines into a single entry. if self .time_gap .is_some_and(|time_gap| self.last_chunk_arrival_time.elapsed() >= time_gap) { let lines = self.buffer.flush(); if !lines.is_empty() { return vec![self.create_patch(lines)]; } self.current_entry_index = None; } } self.last_chunk_arrival_time = Instant::now(); let formatted_chunk = if let Some(format_chunk) = self.format_chunk.as_ref() { format_chunk(self.buffer.partial_line(), text_chunk) } else { text_chunk }; if formatted_chunk.is_empty() { return vec![]; } // Let the buffer handle text buffering self.buffer.ingest(formatted_chunk); if let Some(transform_lines) = self.transform_lines.as_mut() { transform_lines(self.buffer.lines_mut()); self.buffer.recompute_len(); if self.buffer.is_empty() { // Nothing left to process after transformation return vec![]; } } let mut patches = Vec::new(); // Check if we have a custom message boundary predicate loop { let message_boundary_predicate = self .message_boundary_predicate .as_ref() .and_then(|predicate| predicate(self.buffer.lines())); match message_boundary_predicate { // Predicate decided to conclude the current entry at `line_idx` Some(MessageBoundary::Split(line_idx)) => { let lines = self.buffer.drain_lines(line_idx); if !lines.is_empty() { patches.push(self.create_patch(lines)); // Move to next entry after split self.current_entry_index = None; } } // Predicate decided that current content cannot be sent yet. Some(MessageBoundary::IncompleteContent) => { // Stop processing, wait for more content. // Partial updates will be disabled. return patches; } None => { // No more splits, break and continue to size/latency heuristics break; } } } // Check message size. If entry is large enough, break it into smaller entries. if let Some(size_threshold) = self.entry_size_threshold { // Check message size. If entry is large enough, create a new entry. while self.buffer.total_len() >= size_threshold { let lines = self.buffer.drain_size(size_threshold); if lines.is_empty() { break; } patches.push(self.create_patch(lines)); // Move to next entry after size split self.current_entry_index = None; } } // Send partial udpdates if !self.buffer.is_empty() { // Stream updates without consuming buffer patches.push(self.create_patch(self.buffer.lines().to_vec())); } patches } /// Create patch fn create_patch(&mut self, lines: Vec) -> Patch { let content = lines.concat(); let entry = (self.normalized_entry_producer)(content); let added = self.current_entry_index.is_some(); let index = if let Some(idx) = self.current_entry_index { idx } else { // If no current index, get next from provider let idx = self.index_provider.next(); self.current_entry_index = Some(idx); idx }; if !added { ConversationPatch::add_normalized_entry(index, entry) } else { ConversationPatch::replace(index, entry) } } } #[bon] impl PlainTextLogProcessor { /// Create a builder for configuring PlainTextLogProcessor. /// /// # Parameters /// * `normalized_entry_producer` - Required function to convert text content into a `NormalizedEntry`. /// * `size_threshold` - Optional size threshold for individual entries. Once an entry content exceeds this size, a new entry is created. /// * `time_gap` - Optional time gap between individual entries. When new content arrives after this duration, it is considered a new entry. /// * `format_chunk` - Optional function to fix raw output before creating normalized entries. /// * `message_boundary_predicate` - Optional function to determine custom message boundaries. Useful when content is heterogeneous (e.g., tool calls interleaved with assistant messages). /// * `index_provider` - Required sharable atomic counter for tracking entry indices. /// /// When both `size_threshold` and `time_gap` are `None`, a default size threshold of 8 KiB is used. #[builder] pub fn new( normalized_entry_producer: impl Fn(String) -> NormalizedEntry + 'static + Send, size_threshold: Option, time_gap: Option, format_chunk: Option, transform_lines: Option, message_boundary_predicate: Option, index_provider: EntryIndexProvider, ) -> Self { Self { buffer: PlainTextBuffer::new(), index_provider, entry_size_threshold: if size_threshold.is_none() && time_gap.is_none() { Some(8 * 1024) // Default 8KiB when neither is set } else { size_threshold }, time_gap, format_chunk: format_chunk.map(|f| { Box::new(f) as Box, String) -> String + Send + 'static> }), transform_lines: transform_lines .map(|f| Box::new(f) as Box) + Send + 'static>), message_boundary_predicate: message_boundary_predicate.map(|p| { Box::new(p) as Box Option + Send + 'static> }), normalized_entry_producer: Box::new(normalized_entry_producer), last_chunk_arrival_time: Instant::now(), current_entry_index: None, } } } #[cfg(test)] mod tests { use super::*; use crate::logs::{NormalizedEntryType, ToolStatus}; #[test] fn test_plain_buffer_flush() { let mut buffer = PlainTextBuffer::new(); buffer.ingest("line1\npartial".to_string()); assert_eq!(buffer.lines().len(), 2); let lines = buffer.flush(); assert_eq!(lines, vec!["line1\n", "partial"]); assert_eq!(buffer.lines().len(), 0); } #[test] fn test_plain_buffer_len() { let mut buffer = PlainTextBuffer::new(); buffer.ingest("abc\ndef\n".to_string()); assert_eq!(buffer.total_len(), 8); // "abc\n" + "def\n" buffer.drain_lines(1); assert_eq!(buffer.total_len(), 4); // "def\n" } #[test] fn test_drain_until_size() { let mut buffer = PlainTextBuffer::new(); buffer.ingest("short\nlonger line\nvery long line here\n".to_string()); // Drain until we have at least 10 bytes let drained = buffer.drain_size(10); assert_eq!(drained.len(), 2); // "short\n" (6) + "longer line\n" (12) = 18 bytes total assert_eq!(drained, vec!["short\n", "longer line\n"]); } #[test] fn test_processor_simple() { let producer = |content: String| -> NormalizedEntry { NormalizedEntry { timestamp: None, // Avoid creating artificial timestamps during normalization entry_type: NormalizedEntryType::SystemMessage, content: content.to_string(), metadata: None, } }; let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(producer) .index_provider(EntryIndexProvider::test_new()) .build(); let patches = processor.process("hello world\n".to_string()); assert_eq!(patches.len(), 1); } #[test] fn test_processor_custom_log_formatter() { // Example Level 1 producer that parses tool calls let tool_producer = |content: String| -> NormalizedEntry { if content.starts_with("TOOL:") { let tool_name = content.strip_prefix("TOOL:").unwrap_or("unknown").trim(); NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ToolUse { tool_name: tool_name.to_string(), action_type: super::super::ActionType::Other { description: tool_name.to_string(), }, status: ToolStatus::Success, }, content, metadata: None, } } else { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content: content.to_string(), metadata: None, } } }; let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(tool_producer) .index_provider(EntryIndexProvider::test_new()) .build(); let patches = processor.process("TOOL: file_read\n".to_string()); assert_eq!(patches.len(), 1); } #[test] fn test_processor_transform_lines_clears_first_line() { let producer = |content: String| -> NormalizedEntry { NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::SystemMessage, content, metadata: None, } }; let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(producer) .transform_lines(Box::new(|lines: &mut Vec| { // Drop a specific leading banner line if present if !lines.is_empty() && lines.first().map(|s| s.as_str()) == Some("BANNER LINE TO DROP\n") { lines.remove(0); } })) .index_provider(EntryIndexProvider::test_new()) .build(); // Provide a single-line chunk. The transform removes it, leaving nothing to emit. let patches = processor.process("BANNER LINE TO DROP\n".to_string()); assert_eq!(patches.len(), 0); // Next, add actual content; should emit one patch with the content let patches = processor.process("real content\n".to_string()); assert_eq!(patches.len(), 1); } } ================================================ FILE: crates/executors/src/logs/stderr_processor.rs ================================================ //! Standard stderr log processor for executors //! //! Uses `PlainTextLogProcessor` with a 2-second `latency_threshold` to split stderr streams into entries. //! Each entry is normalized as `ErrorMessage` and emitted as JSON patches to the message store. //! //! Example: //! ```rust,ignore //! normalize_stderr_logs(msg_store.clone(), EntryIndexProvider::new()); //! ``` //! use std::{sync::Arc, time::Duration}; use futures::StreamExt; use workspace_utils::msg_store::MsgStore; use super::{ NormalizedEntry, NormalizedEntryError, NormalizedEntryType, plain_text_processor::PlainTextLogProcessor, }; use crate::logs::utils::EntryIndexProvider; /// Standard stderr log normalizer that uses PlainTextLogProcessor to stream error logs. /// /// Splits stderr output into discrete entries based on a latency threshold (2s) to group /// related lines into a single error entry. Each entry is normalized as an `ErrorMessage` /// and emitted as JSON patches for downstream consumption (e.g., UI or log aggregation). /// /// # Options /// - `latency_threshold`: 2 seconds to separate error messages based on time gaps. /// - `normalized_entry_producer`: maps each chunk into an `ErrorMessage` entry. /// /// # Use case /// Intended for executor stderr streams, grouping multi-line errors into cohesive entries /// instead of emitting each line separately. /// /// # Arguments /// * `msg_store` - the message store providing a stream of stderr chunks and accepting patches. /// * `entry_index_provider` - provider of incremental entry indices for patch ordering. pub fn normalize_stderr_logs( msg_store: Arc, entry_index_provider: EntryIndexProvider, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stderr = msg_store.stderr_chunked_stream(); // Create a processor with time-based emission for stderr let mut processor = PlainTextLogProcessor::builder() .normalized_entry_producer(Box::new(|content: String| NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::Other, }, content: strip_ansi_escapes::strip_str(&content), metadata: None, })) .time_gap(Duration::from_secs(2)) // Break messages if they are 2 seconds apart .index_provider(entry_index_provider) .build(); while let Some(Ok(chunk)) = stderr.next().await { for patch in processor.process(chunk) { msg_store.push_patch(patch); } } }) } ================================================ FILE: crates/executors/src/logs/utils/entry_index.rs ================================================ //! Entry Index Provider for thread-safe monotonic indexing use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; use json_patch::PatchOperation; use workspace_utils::{log_msg::LogMsg, msg_store::MsgStore}; /// Thread-safe provider for monotonically increasing entry indexes #[derive(Debug, Clone)] pub struct EntryIndexProvider(Arc); impl EntryIndexProvider { /// Create a new index provider starting from 0 (private; prefer seeding) fn new() -> Self { Self(Arc::new(AtomicUsize::new(0))) } /// Get the next available index pub fn next(&self) -> usize { self.0.fetch_add(1, Ordering::Relaxed) } /// Get the current index without incrementing pub fn current(&self) -> usize { self.0.load(Ordering::Relaxed) } pub fn reset(&self) { self.0.store(0, Ordering::Relaxed); } /// Create a provider starting from the maximum existing normalized-entry index /// observed in prior JSON patches in `MsgStore`. pub fn start_from(msg_store: &MsgStore) -> Self { let provider = EntryIndexProvider::new(); let max_index: Option = msg_store .get_history() .iter() .filter_map(|msg| { if let LogMsg::JsonPatch(patch) = msg { patch.iter().find_map(|op| { if let PatchOperation::Add(add) = op { add.path .strip_prefix("/entries/") .and_then(|n_str| n_str.parse::().ok()) } else { None } }) } else { None } }) .max(); let start_at = max_index.map_or(0, |n| n.saturating_add(1)); provider.0.store(start_at, Ordering::Relaxed); provider } } impl Default for EntryIndexProvider { fn default() -> Self { Self::new() } } #[cfg(test)] impl EntryIndexProvider { /// Test-only constructor for a fresh provider starting at 0 pub fn test_new() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_entry_index_provider() { let provider = EntryIndexProvider::test_new(); assert_eq!(provider.next(), 0); assert_eq!(provider.next(), 1); assert_eq!(provider.next(), 2); } #[test] fn test_entry_index_provider_clone() { let provider1 = EntryIndexProvider::test_new(); let provider2 = provider1.clone(); assert_eq!(provider1.next(), 0); assert_eq!(provider2.next(), 1); assert_eq!(provider1.next(), 2); } #[test] fn test_current_index() { let provider = EntryIndexProvider::test_new(); assert_eq!(provider.current(), 0); provider.next(); assert_eq!(provider.current(), 1); provider.next(); assert_eq!(provider.current(), 2); } } ================================================ FILE: crates/executors/src/logs/utils/mod.rs ================================================ //! Utility modules for executor framework pub mod entry_index; pub mod patch; pub use entry_index::EntryIndexProvider; pub use patch::ConversationPatch; pub mod shell_command_parsing; ================================================ FILE: crates/executors/src/logs/utils/patch.rs ================================================ use std::{collections::HashSet, sync::Arc}; use json_patch::Patch; use serde::{Deserialize, Serialize}; use serde_json::{from_value, json, to_value}; use ts_rs::TS; use workspace_utils::{diff::Diff, msg_store::MsgStore}; use crate::{ executor_discovery::ExecutorDiscoveredOptions, executors::SlashCommandDescription, logs::{NormalizedEntry, utils::EntryIndexProvider}, }; #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, TS)] #[serde(rename_all = "lowercase")] enum PatchOperation { Add, Replace, Remove, } #[allow(clippy::large_enum_variant)] #[derive(Serialize, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "type", content = "content")] pub enum PatchType { NormalizedEntry(NormalizedEntry), Stdout(String), Stderr(String), Diff(Diff), } #[derive(Serialize)] struct PatchEntry { op: PatchOperation, path: String, value: PatchType, } pub fn escape_json_pointer_segment(s: &str) -> String { s.replace('~', "~0").replace('/', "~1") } /// Helper functions to create JSON patches for conversation entries pub struct ConversationPatch; impl ConversationPatch { /// Create an ADD patch for a new conversation entry at the given index pub fn add_normalized_entry(entry_index: usize, entry: NormalizedEntry) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Add, path: format!("/entries/{entry_index}"), value: PatchType::NormalizedEntry(entry), }; from_value(json!([patch_entry])).unwrap() } /// Create an ADD patch for a new string at the given index pub fn add_stdout(entry_index: usize, entry: String) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Add, path: format!("/entries/{entry_index}"), value: PatchType::Stdout(entry), }; from_value(json!([patch_entry])).unwrap() } /// Create an ADD patch for a new string at the given index pub fn add_stderr(entry_index: usize, entry: String) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Add, path: format!("/entries/{entry_index}"), value: PatchType::Stderr(entry), }; from_value(json!([patch_entry])).unwrap() } /// Create an ADD patch for a new diff at the given index pub fn add_diff(entry_index: String, diff: Diff) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Add, path: format!("/entries/{entry_index}"), value: PatchType::Diff(diff), }; from_value(json!([patch_entry])).unwrap() } /// Create an ADD patch for a new diff at the given index pub fn replace_diff(entry_index: String, diff: Diff) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Replace, path: format!("/entries/{entry_index}"), value: PatchType::Diff(diff), }; from_value(json!([patch_entry])).unwrap() } /// Create a REMOVE patch for removing a diff pub fn remove_diff(entry_index: String) -> Patch { from_value(json!([{ "op": PatchOperation::Remove, "path": format!("/entries/{entry_index}"), }])) .unwrap() } /// Create a REPLACE patch for updating an existing conversation entry at the given index pub fn replace(entry_index: usize, entry: NormalizedEntry) -> Patch { let patch_entry = PatchEntry { op: PatchOperation::Replace, path: format!("/entries/{entry_index}"), value: PatchType::NormalizedEntry(entry), }; from_value(json!([patch_entry])).unwrap() } pub fn remove(entry_index: usize) -> Patch { from_value(json!([{ "op": PatchOperation::Remove, "path": format!("/entries/{entry_index}"), }])) .unwrap() } } /// Extract the entry index and `NormalizedEntry` from a JsonPatch if it contains one pub fn extract_normalized_entry_from_patch(patch: &Patch) -> Option<(usize, NormalizedEntry)> { let value = to_value(patch).ok()?; let ops = value.as_array()?; ops.iter().rev().find_map(|op| { let path = op.get("path")?.as_str()?; let entry_index = path.strip_prefix("/entries/")?.parse::().ok()?; let value = op.get("value")?; (value.get("type")?.as_str()? == "NORMALIZED_ENTRY") .then(|| value.get("content")) .flatten() .and_then(|c| from_value::(c.clone()).ok()) .map(|entry| (entry_index, entry)) }) } pub fn upsert_normalized_entry( msg_store: &Arc, index: usize, normalized_entry: NormalizedEntry, is_new: bool, ) { if is_new { msg_store.push_patch(ConversationPatch::add_normalized_entry( index, normalized_entry, )); } else { msg_store.push_patch(ConversationPatch::replace(index, normalized_entry)); } } pub fn add_normalized_entry( msg_store: &Arc, index_provider: &EntryIndexProvider, normalized_entry: NormalizedEntry, ) -> usize { let index = index_provider.next(); upsert_normalized_entry(msg_store, index, normalized_entry, true); index } pub fn replace_normalized_entry( msg_store: &Arc, index: usize, normalized_entry: NormalizedEntry, ) { upsert_normalized_entry(msg_store, index, normalized_entry, false); } /// Extract the path string from a Patch (assumes single-operation patches). pub fn patch_entry_path(patch: &Patch) -> Option { patch.0.first().map(|op| op.path().to_string()) } pub fn is_add_or_replace(patch: &Patch) -> bool { use json_patch::PatchOperation::*; patch.0.iter().all(|op| matches!(op, Add(..) | Replace(..))) } // Use the "replace" op for sent paths and "add" for new paths pub fn fix_patch_ops(mut patch: Patch, sent_paths: &mut HashSet) -> Patch { for op in &mut patch.0 { let path_sent = sent_paths.contains(op.path().as_str()); match op { json_patch::PatchOperation::Add(add) if path_sent => { *op = json_patch::PatchOperation::Replace(json_patch::ReplaceOperation { path: add.path.clone(), value: add.value.clone(), }); } json_patch::PatchOperation::Replace(replace) if !path_sent => { *op = json_patch::PatchOperation::Add(json_patch::AddOperation { path: replace.path.clone(), value: replace.value.clone(), }); } _ => {} }; if !path_sent { sent_paths.insert(op.path().to_string()); } } patch } pub fn executor_discovered_options(options: ExecutorDiscoveredOptions) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options", "value": options}, ])) .unwrap_or_default() } pub fn slash_commands( commands: Vec, discovering: bool, error: Option, ) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/commands", "value": commands}, {"op": "replace", "path": "/discovering", "value": discovering}, {"op": "replace", "path": "/error", "value": error}, ])) .unwrap_or_default() } pub fn update_models(models: Vec) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/model_selector/models", "value": models}, ])) .unwrap_or_default() } pub fn models_loaded() -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/loading_models", "value": false}, ])) .unwrap_or_default() } pub fn update_agents(agents: Vec) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/model_selector/agents", "value": agents}, ])) .unwrap_or_default() } pub fn agents_loaded() -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/loading_agents", "value": false}, ])) .unwrap_or_default() } pub fn update_slash_commands( slash_commands: Vec, ) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/slash_commands", "value": slash_commands}, ])) .unwrap_or_default() } pub fn slash_commands_loaded() -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/loading_slash_commands", "value": false}, ])) .unwrap_or_default() } pub fn update_providers(providers: Vec) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/model_selector/providers", "value": providers}, ])) .unwrap_or_default() } pub fn update_default_model(default_model: Option) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/model_selector/default_model", "value": default_model}, ])) .unwrap_or_default() } pub fn discovery_error(error: String) -> Patch { serde_json::from_value(json!([ {"op": "replace", "path": "/options/error", "value": error}, {"op": "replace", "path": "/options/loading_models", "value": false}, {"op": "replace", "path": "/options/loading_agents", "value": false}, {"op": "replace", "path": "/options/loading_slash_commands", "value": false}, ])) .unwrap_or_default() } ================================================ FILE: crates/executors/src/logs/utils/shell_command_parsing.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// Simple categories for common bash commands #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Default)] #[serde(rename_all = "snake_case")] pub enum CommandCategory { /// File reading commands (cat, head, tail, sed without -i) Read, /// File/directory search commands (grep, rg, find, awk) Search, /// File editing commands (any command with >, sed -i, tee, chmod, rm, mv, cp) Edit, /// Network fetch commands (curl, wget) Fetch, /// Default category for everything else #[default] Other, } impl CommandCategory { /// Categorize a bash command string. pub fn from_command(command: &str) -> Self { let command = command.trim(); if command.is_empty() { return Self::Other; } let command = unwrap_shell_command(command); // Any output redirect to a real file is an edit operation, e.g. echo > file if has_file_redirect(command) { return Self::Edit; } let cmd = command .split_whitespace() .next() .and_then(|s| s.rsplit('/').next()) .unwrap_or("") .to_lowercase(); match cmd.as_str() { // File reading commands (ls lists directory contents) "cat" | "head" | "tail" | "zcat" | "gzcat" | "ls" => Self::Read, // Search commands "grep" | "rg" | "find" | "awk" => Self::Search, // sed: -i means in-place edit, otherwise read-only "sed" if command.contains("-i") => Self::Edit, "sed" => Self::Read, // Direct file edits "tee" | "truncate" | "chmod" | "chown" | "rm" | "mv" | "cp" | "touch" | "ln" => { Self::Edit } // Web Fetch commands "curl" | "wget" => Self::Fetch, _ => Self::Other, } } } /// Check whether a command contains a redirect to an actual file (not `/dev/null` or fd dup). /// /// Uses shlex to tokenize (handles quoting), then looks for tokens containing `>` /// and checks whether the redirect target is a real file. fn has_file_redirect(command: &str) -> bool { if !command.contains('>') { return false; } let tokens: Vec = shlex::Shlex::new(command).collect(); let mut i = 0; while i < tokens.len() { let t = &tokens[i]; if let Some(target) = redirect_target(t) { if is_file_target(target) { return true; } } else if (t == ">" || t == ">>" || t.ends_with('>') || t.ends_with(">>")) && let Some(next) = tokens.get(i + 1) { if is_file_target(next) { return true; } i += 1; } i += 1; } false } /// Given a token containing `>`, extract the redirect target if it's inline. /// E.g. ">file" => Some("file"), "2>/dev/null" => Some("/dev/null"), ">" => None fn redirect_target(token: &str) -> Option<&str> { let pos = token.find('>')?; let after = &token[pos + 1..]; let after = after.strip_prefix('>').unwrap_or(after); if after.is_empty() { None } else { Some(after) } } /// Returns true if the redirect target is a real file (not /dev/null or &fd). fn is_file_target(target: &str) -> bool { !target.starts_with('&') && target != "/dev/null" } /// Unwrap shell wrappers to get the actual command. /// /// Handles: `zsh -c "command"` / `bash -lc 'command'` / etc. pub fn unwrap_shell_command(command: &str) -> &str { let mut remaining = command; loop { let trimmed = remaining.trim_start(); // Find first word let first_word_end = trimmed .find(|c: char| c.is_whitespace()) .unwrap_or(trimmed.len()); let first_word = &trimmed[..first_word_end]; let cmd_name = first_word.rsplit('/').next().unwrap_or(first_word); // Check for shell -c "command" if matches!(cmd_name, "sh" | "bash" | "zsh") { let after_cmd = &trimmed[first_word_end..]; if let Some(cmd_str) = extract_command_after_c_flag(after_cmd) { remaining = cmd_str; continue; } } break; } remaining } /// Extract the command string after a -c flag in shell arguments. /// Handles: -c 'cmd', -c "cmd", -lc cmd, -cl 'cmd', etc. fn extract_command_after_c_flag(args: &str) -> Option<&str> { let mut idx = 0; while idx < args.len() { let remaining = &args[idx..]; let dash_pos = remaining.find('-')?; let after_dash = &remaining[dash_pos + 1..]; let flag_end = after_dash .find(|c: char| !c.is_alphabetic()) .unwrap_or(after_dash.len()); let flags = &after_dash[..flag_end]; if flags.contains('c') { let cmd_start = dash_pos + 1 + flag_end; return Some(strip_quotes(remaining[cmd_start..].trim_start())); } idx += dash_pos + 1 + flag_end; } None } /// Strip surrounding quotes from a command string. fn strip_quotes(s: &str) -> &str { let s = s.trim(); if s.len() >= 2 { let first = s.as_bytes()[0]; let last = s.as_bytes()[s.len() - 1]; if (first == b'"' || first == b'\'') && first == last { return &s[1..s.len() - 1]; } } s } ================================================ FILE: crates/executors/src/mcp_config.rs ================================================ //! Utilities for reading and writing external agent config files (not the server's own config). //! //! These helpers abstract over JSON vs TOML vs JSONC formats used by different agents. //! JSONC (JSON with Comments) is supported with comment preservation using jsonc-parser's CST. use std::{collections::HashMap, path::Path, sync::LazyLock}; use jsonc_parser::{ ParseOptions, cst::{CstObject, CstRootNode}, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tokio::fs; use ts_rs::TS; use crate::executors::{CodingAgent, ExecutorError}; fn is_jsonc_file(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("jsonc")) } static DEFAULT_MCP_JSON: &str = include_str!("../default_mcp.json"); pub static PRECONFIGURED_MCP_SERVERS: LazyLock = LazyLock::new(|| { serde_json::from_str::(DEFAULT_MCP_JSON).expect("Failed to parse default MCP JSON") }); #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct McpConfig { servers: HashMap, pub servers_path: Vec, pub template: serde_json::Value, pub preconfigured: serde_json::Value, pub is_toml_config: bool, } impl McpConfig { pub fn new( servers_path: Vec, template: serde_json::Value, preconfigured: serde_json::Value, is_toml_config: bool, ) -> Self { Self { servers: HashMap::new(), servers_path, template, preconfigured, is_toml_config, } } pub fn set_servers(&mut self, servers: HashMap) { self.servers = servers; } } pub async fn read_agent_config( config_path: &std::path::Path, mcp_config: &McpConfig, ) -> Result { if let Ok(file_content) = fs::read_to_string(config_path).await { if mcp_config.is_toml_config { if file_content.trim().is_empty() { return Ok(serde_json::json!({})); } let toml_val: toml::Value = toml::from_str(&file_content)?; let json_string = serde_json::to_string(&toml_val)?; Ok(serde_json::from_str(&json_string)?) } else if is_jsonc_file(config_path) { if file_content.trim().is_empty() { return Ok(serde_json::json!({})); } match jsonc_parser::parse_to_serde_value(&file_content, &ParseOptions::default()) { Ok(Some(value)) => Ok(value), Ok(None) => Ok(serde_json::json!({})), Err(_) => Ok(serde_json::from_str(&file_content)?), } } else { Ok(serde_json::from_str(&file_content)?) } } else { Ok(mcp_config.template.clone()) } } pub async fn write_agent_config( config_path: &std::path::Path, mcp_config: &McpConfig, config: &Value, ) -> Result<(), ExecutorError> { if mcp_config.is_toml_config { let toml_value: toml::Value = serde_json::from_str(&serde_json::to_string(config)?)?; let toml_content = toml::to_string_pretty(&toml_value)?; fs::write(config_path, toml_content).await?; } else if is_jsonc_file(config_path) { write_jsonc_preserving_comments(config_path, config).await?; } else { let json_content = serde_json::to_string_pretty(config)?; fs::write(config_path, json_content).await?; } Ok(()) } async fn write_jsonc_preserving_comments( config_path: &std::path::Path, new_config: &Value, ) -> Result<(), ExecutorError> { let current_content = fs::read_to_string(config_path) .await .unwrap_or_else(|_| "{}".to_string()); let output = update_jsonc_content(¤t_content, new_config); fs::write(config_path, output).await?; Ok(()) } fn update_jsonc_content(current_content: &str, new_config: &Value) -> String { let root = CstRootNode::parse(current_content, &ParseOptions::default()) .unwrap_or_else(|_| CstRootNode::parse("{}", &ParseOptions::default()).unwrap()); let root_obj = root.object_value_or_set(); if let Some(obj) = new_config.as_object() { deep_merge_cst_object(&root_obj, obj); } root.to_string() } /// Recursively merges a serde_json Map into an existing CST object. /// This preserves comments by navigating into existing nested objects rather than replacing them. fn deep_merge_cst_object(cst_obj: &CstObject, new_obj: &Map) { let existing_keys: Vec = cst_obj .properties() .iter() .filter_map(|p| p.name().and_then(|n| n.decoded_value().ok())) .collect(); for key in &existing_keys { if !new_obj.contains_key(key) && let Some(prop) = cst_obj.get(key) { prop.remove(); } } for (key, new_value) in new_obj { if let Some(prop) = cst_obj.get(key) { if let (Some(existing_obj), Some(new_obj_map)) = (prop.object_value(), new_value.as_object()) { deep_merge_cst_object(&existing_obj, new_obj_map); } else { prop.set_value(serde_json_to_cst_input(new_value)); } } else { cst_obj.append(key, serde_json_to_cst_input(new_value)); } } } fn serde_json_to_cst_input(value: &Value) -> jsonc_parser::cst::CstInputValue { use jsonc_parser::cst::CstInputValue; match value { Value::Null => CstInputValue::Null, Value::Bool(b) => CstInputValue::Bool(*b), Value::Number(n) => { if let Some(i) = n.as_i64() { CstInputValue::Number(i.to_string()) } else if let Some(f) = n.as_f64() { CstInputValue::Number(f.to_string()) } else { CstInputValue::Number(n.to_string()) } } Value::String(s) => CstInputValue::String(s.clone()), Value::Array(arr) => { CstInputValue::Array(arr.iter().map(serde_json_to_cst_input).collect()) } Value::Object(obj) => CstInputValue::Object( obj.iter() .map(|(k, v)| (k.clone(), serde_json_to_cst_input(v))) .collect(), ), } } type ServerMap = Map; fn is_http_server(s: &Map) -> bool { matches!(s.get("type").and_then(Value::as_str), Some("http")) } fn is_stdio(s: &Map) -> bool { !is_http_server(s) && s.get("command").is_some() } fn extract_meta(mut obj: ServerMap) -> (ServerMap, Option) { let meta = obj.remove("meta"); (obj, meta) } fn attach_meta(mut obj: ServerMap, meta: Option) -> Value { if let Some(m) = meta { obj.insert("meta".to_string(), m); } Value::Object(obj) } fn ensure_header(headers: &mut Map, key: &str, val: &str) { match headers.get_mut(key) { Some(Value::String(_)) => {} _ => { headers.insert(key.to_string(), Value::String(val.to_string())); } } } fn transform_http_servers(mut servers: ServerMap, mut f: F) -> ServerMap where F: FnMut(Map) -> Map, { for (_k, v) in servers.iter_mut() { if let Value::Object(s) = v && is_http_server(s) { let taken = std::mem::take(s); *s = f(taken); } } servers } // --- Adapters --------------------------------------------------------------- fn adapt_passthrough(servers: ServerMap, meta: Option) -> Value { attach_meta(servers, meta) } fn adapt_gemini(servers: ServerMap, meta: Option) -> Value { let servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let mut headers = s .remove("headers") .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); ensure_header( &mut headers, "Accept", "application/json, text/event-stream", ); Map::from_iter([ ("httpUrl".to_string(), url), ("headers".to_string(), Value::Object(headers)), ]) }); attach_meta(servers, meta) } fn adapt_cursor(servers: ServerMap, meta: Option) -> Value { let servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let headers = s .remove("headers") .unwrap_or_else(|| Value::Object(Default::default())); Map::from_iter([("url".to_string(), url), ("headers".to_string(), headers)]) }); attach_meta(servers, meta) } fn adapt_codex(mut servers: ServerMap, mut meta: Option) -> Value { servers.retain(|_, v| v.as_object().map(is_stdio).unwrap_or(false)); if let Some(Value::Object(ref mut m)) = meta { m.retain(|k, _| servers.contains_key(k)); servers.insert("meta".to_string(), Value::Object(std::mem::take(m))); meta = None; // already attached above } attach_meta(servers, meta) } fn adapt_opencode(servers: ServerMap, meta: Option) -> Value { let mut servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let mut headers = s .remove("headers") .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); ensure_header( &mut headers, "Accept", "application/json, text/event-stream", ); Map::from_iter([ ("type".to_string(), Value::String("remote".to_string())), ("url".to_string(), url), ("headers".to_string(), Value::Object(headers)), ("enabled".to_string(), Value::Bool(true)), ]) }); for (_k, v) in servers.iter_mut() { if let Value::Object(s) = v && is_stdio(s) { let command_str = s .remove("command") .and_then(|v| match v { Value::String(s) => Some(s), _ => None, }) .unwrap_or_default(); let mut cmd_vec: Vec = Vec::new(); if !command_str.is_empty() { cmd_vec.push(Value::String(command_str)); } if let Some(arr) = s.remove("args").and_then(|v| match v { Value::Array(arr) => Some(arr), _ => None, }) { for a in arr { match a { Value::String(s) => cmd_vec.push(Value::String(s)), other => cmd_vec.push(other), // fall back to raw value if not string } } } let mut new_map = Map::new(); new_map.insert("type".to_string(), Value::String("local".to_string())); new_map.insert("command".to_string(), Value::Array(cmd_vec)); new_map.insert("enabled".to_string(), Value::Bool(true)); *s = new_map; } } attach_meta(servers, meta) } fn adapt_copilot(mut servers: ServerMap, meta: Option) -> Value { for (_, value) in servers.iter_mut() { if let Value::Object(s) = value && !s.contains_key("tools") { s.insert( "tools".to_string(), Value::Array(vec![Value::String("*".to_string())]), ); } } attach_meta(servers, meta) } enum Adapter { Passthrough, Gemini, Cursor, Codex, Opencode, Copilot, } fn apply_adapter(adapter: Adapter, canonical: Value) -> Value { let (servers_only, meta) = match canonical.as_object() { Some(map) => extract_meta(map.clone()), None => (ServerMap::new(), None), }; match adapter { Adapter::Passthrough => adapt_passthrough(servers_only, meta), Adapter::Gemini => adapt_gemini(servers_only, meta), Adapter::Cursor => adapt_cursor(servers_only, meta), Adapter::Codex => adapt_codex(servers_only, meta), Adapter::Opencode => adapt_opencode(servers_only, meta), Adapter::Copilot => adapt_copilot(servers_only, meta), } } impl CodingAgent { pub fn preconfigured_mcp(&self) -> Value { use Adapter::*; let adapter = match self { CodingAgent::ClaudeCode(_) | CodingAgent::Amp(_) | CodingAgent::Droid(_) => Passthrough, CodingAgent::QwenCode(_) | CodingAgent::Gemini(_) => Gemini, CodingAgent::CursorAgent(_) => Cursor, CodingAgent::Codex(_) => Codex, CodingAgent::Opencode(_) => Opencode, CodingAgent::Copilot(..) => Copilot, #[cfg(feature = "qa-mode")] CodingAgent::QaMock(_) => Passthrough, // QA mock doesn't need MCP }; let canonical = PRECONFIGURED_MCP_SERVERS.clone(); apply_adapter(adapter, canonical) } } ================================================ FILE: crates/executors/src/model_selector.rs ================================================ use convert_case::{Case, Casing}; use serde::{Deserialize, Serialize}; use ts_rs::TS; /// Provider information #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ModelProvider { /// Provider identifier pub id: String, /// Display name pub name: String, } /// Basic model information #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ModelInfo { /// Model identifier pub id: String, /// Display name pub name: String, /// Provider this model belongs to #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_id: Option, /// Configurable reasoning options if supported #[serde(default)] pub reasoning_options: Vec, } /// Reasoning option (simple selectable choice). #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ReasoningOption { pub id: String, pub label: String, #[serde(default)] pub is_default: bool, } /// Available agent option provided by an executor. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AgentInfo { pub id: String, pub label: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default)] pub is_default: bool, } /// Permission policy for tool operations #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq, Eq, Default)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[ts(use_ts_enum)] pub enum PermissionPolicy { #[default] /// Skip all permission checks Auto, /// Require approval for risky operations Supervised, /// Plan mode before execution (executor-defined meaning) Plan, } /// Full model selector configuration #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] pub struct ModelSelectorConfig { /// Available providers pub providers: Vec, /// Available models pub models: Vec, /// Global default model (format: provider_id/model_id) #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, /// Available agents pub agents: Vec, /// Supported permission policies pub permissions: Vec, } impl ReasoningOption { pub fn from_names(names: impl IntoIterator>) -> Vec { Self::from_names_with_labels(names.into_iter().map(|n| (n.into(), None))) } pub fn from_names_with_labels( pairs: impl IntoIterator)>, ) -> Vec { let rank_key = |id: &str| match id.to_lowercase().as_str() { "none" => Some(0), "low" => Some(1), "medium" => Some(2), "high" => Some(3), "xhigh" => Some(4), "max" => Some(5), _ => None, }; let mut options: Vec = pairs .into_iter() .map(|(id, label)| { let label = label.unwrap_or_else(|| reasoning_label(&id)); let is_default = id.eq_ignore_ascii_case("high"); ReasoningOption { id, label, is_default, } }) .collect(); options.sort_by(|a, b| match (rank_key(&a.id), rank_key(&b.id)) { (Some(a_rank), Some(b_rank)) => a_rank.cmp(&b_rank), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a.label.cmp(&b.label), }); options } } fn reasoning_label(id: &str) -> String { match id { "xhigh" => "Extra High".to_string(), _ => id.to_case(Case::Title), } } ================================================ FILE: crates/executors/src/profile.rs ================================================ use std::{ collections::HashMap, fs, str::FromStr, sync::{LazyLock, RwLock}, }; use convert_case::{Case, Casing}; use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError}; use thiserror::Error; use ts_rs::TS; use crate::{ executors::{AvailabilityInfo, BaseCodingAgent, CodingAgent, StandardCodingAgentExecutor}, model_selector::PermissionPolicy, }; /// Return the canonical form for variant keys. /// – "DEFAULT" is kept as-is /// – everything else is converted to SCREAMING_SNAKE_CASE pub fn canonical_variant_key>(raw: S) -> String { let key = raw.as_ref(); if key.eq_ignore_ascii_case("DEFAULT") { "DEFAULT".to_string() } else { // Convert to SCREAMING_SNAKE_CASE by first going to snake_case then uppercase key.to_case(Case::Snake).to_case(Case::ScreamingSnake) } } #[derive(Error, Debug)] pub enum ProfileError { #[error("Built-in executor '{executor}' cannot be deleted")] CannotDeleteExecutor { executor: BaseCodingAgent }, #[error("Built-in configuration '{executor}:{variant}' cannot be deleted")] CannotDeleteBuiltInConfig { executor: BaseCodingAgent, variant: String, }, #[error("Validation error: {0}")] Validation(String), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Serde(#[from] serde_json::Error), #[error("No available executor profile")] NoAvailableExecutorProfile, } static EXECUTOR_PROFILES_CACHE: LazyLock> = LazyLock::new(|| RwLock::new(ExecutorConfigs::load())); // New format default profiles (v3 - flattened) const DEFAULT_PROFILES_JSON: &str = include_str!("../default_profiles.json"); // Executor-centric profile identifier #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Hash, Eq)] pub struct ExecutorProfileId { /// The executor type (e.g., "CLAUDE_CODE", "AMP") #[serde(alias = "profile", deserialize_with = "de_base_coding_agent_kebab")] // Backwards compatibility with ProfileVariantIds, esp stored in DB under ExecutorAction pub executor: BaseCodingAgent, /// Optional variant name (e.g., "PLAN", "ROUTER") #[serde(skip_serializing_if = "Option::is_none")] pub variant: Option, } // Convert legacy profile/executor names from kebab-case to SCREAMING_SNAKE_CASE, can be deleted 14 days from 3/9/25 fn de_base_coding_agent_kebab<'de, D>(de: D) -> Result where D: Deserializer<'de>, { let raw = String::deserialize(de)?; // kebab-case -> SCREAMING_SNAKE_CASE let norm = raw.replace('-', "_").to_ascii_uppercase(); BaseCodingAgent::from_str(&norm) .map_err(|_| D::Error::custom(format!("unknown executor '{raw}' (normalized to '{norm}')"))) } impl ExecutorProfileId { /// Create a new executor profile ID with default variant pub fn new(executor: BaseCodingAgent) -> Self { Self { executor, variant: None, } } /// Create a new executor profile ID with specific variant pub fn with_variant(executor: BaseCodingAgent, variant: String) -> Self { Self { executor, variant: Some(variant), } } /// Get cache key for this executor profile pub fn cache_key(&self) -> String { match &self.variant { Some(variant) => format!("{}:{}", self.executor, variant), None => self.executor.clone().to_string(), } } } impl std::fmt::Display for ExecutorProfileId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.variant { Some(variant) => write!(f, "{}:{}", self.executor, variant), None => write!(f, "{}", self.executor), } } } /// Unified executor identity + user-selectable overrides. /// /// This is the single object that flows through API requests, action types, /// scratch persistence, and frontend state whenever an executor is used. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ExecutorConfig { /// The executor type (e.g., CLAUDE_CODE, AMP) #[serde(alias = "profile", deserialize_with = "de_base_coding_agent_kebab")] pub executor: BaseCodingAgent, /// Optional variant/preset name (e.g., "PLAN", "ROUTER") #[serde(default, skip_serializing_if = "Option::is_none")] pub variant: Option, /// Model override (e.g., "anthropic/claude-sonnet-4-20250514") #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, /// Agent mode override #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_id: Option, /// Reasoning effort override (e.g., "high", "medium") #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_id: Option, /// Permission policy override #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_policy: Option, } impl ExecutorConfig { /// Create from just an executor (default variant, no overrides) pub fn new(executor: BaseCodingAgent) -> Self { Self { executor, variant: None, model_id: None, agent_id: None, reasoning_id: None, permission_policy: None, } } /// Extract the profile identity portion for profile lookup pub fn profile_id(&self) -> ExecutorProfileId { ExecutorProfileId { executor: self.executor, variant: self.variant.clone(), } } /// Returns true if any override field is set pub fn has_overrides(&self) -> bool { self.model_id.is_some() || self.agent_id.is_some() || self.reasoning_id.is_some() || self.permission_policy.is_some() } } impl From for ExecutorConfig { fn from(id: ExecutorProfileId) -> Self { Self { executor: id.executor, variant: id.variant, model_id: None, agent_id: None, reasoning_id: None, permission_policy: None, } } } impl std::fmt::Display for ExecutorConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.profile_id().fmt(f) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ExecutorProfile { #[serde(default, skip_serializing_if = "Option::is_none")] pub recently_used_models: Option, #[serde(flatten)] pub configurations: HashMap, } impl ExecutorProfile { /// Get variant configuration by name, or None if not found pub fn get_variant(&self, variant: &str) -> Option<&CodingAgent> { self.configurations.get(variant) } /// Get the default configuration for this executor pub fn get_default(&self) -> Option<&CodingAgent> { self.configurations.get("DEFAULT") } /// Create a new executor profile with just a default configuration pub fn new_with_default(default_config: CodingAgent) -> Self { let mut configurations = HashMap::new(); configurations.insert("DEFAULT".to_string(), default_config); Self { recently_used_models: None, configurations, } } /// Add or update a variant configuration pub fn set_variant( &mut self, variant_name: String, config: CodingAgent, ) -> Result<(), &'static str> { let key = canonical_variant_key(&variant_name); if key == "DEFAULT" { return Err( "Cannot override 'DEFAULT' variant using set_variant, use set_default instead", ); } self.configurations.insert(key, config); Ok(()) } /// Set the default configuration pub fn set_default(&mut self, config: CodingAgent) { self.configurations.insert("DEFAULT".to_string(), config); } /// Get all variant names (excluding "DEFAULT") pub fn variant_names(&self) -> Vec<&String> { self.configurations .keys() .filter(|k| *k != "DEFAULT") .collect() } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)] pub struct ExecutorRecentModels { /// Ordered list of recently used model keys (most recent last). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, /// Last-used reasoning effort per model #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub reasoning_by_model: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ExecutorConfigs { pub executors: HashMap, } impl ExecutorConfigs { /// Normalise all variant keys in-place fn canonicalise(&mut self) { for profile in self.executors.values_mut() { let mut replacements = Vec::new(); for key in profile.configurations.keys().cloned().collect::>() { let canon = canonical_variant_key(&key); if canon != key { replacements.push((key, canon)); } } for (old, new) in replacements { if let Some(cfg) = profile.configurations.remove(&old) { // If both lowercase and canonical forms existed, keep canonical one profile.configurations.entry(new).or_insert(cfg); } } } } /// Get cached executor profiles pub fn get_cached() -> ExecutorConfigs { EXECUTOR_PROFILES_CACHE.read().unwrap().clone() } /// Reload executor profiles cache pub fn reload() { let mut cache = EXECUTOR_PROFILES_CACHE.write().unwrap(); *cache = Self::load(); } /// Load executor profiles from file or defaults pub fn load() -> Self { let profiles_path = workspace_utils::assets::profiles_path(); // Load defaults first let mut defaults = Self::from_defaults(); defaults.canonicalise(); // Try to load user overrides let content = match fs::read_to_string(&profiles_path) { Ok(content) => content, Err(_) => { tracing::info!("No user profiles.json found, using defaults only"); return defaults; } }; // Parse user overrides match serde_json::from_str::(&content) { Ok(mut user_overrides) => { tracing::info!("Loaded user profile overrides from profiles.json"); user_overrides.canonicalise(); Self::merge_with_defaults(defaults, user_overrides) } Err(e) => { tracing::error!( "Failed to parse user profiles.json: {}, using defaults only", e ); defaults } } } /// Save user profile overrides to file (only saves what differs from defaults) pub fn save_overrides(&self) -> Result<(), ProfileError> { let profiles_path = workspace_utils::assets::profiles_path(); let mut defaults = Self::from_defaults(); defaults.canonicalise(); // Canonicalise current config before computing overrides let mut self_clone = self.clone(); self_clone.canonicalise(); // Compute differences from defaults let overrides = Self::compute_overrides(&defaults, &self_clone)?; // Validate the merged result would be valid let merged = Self::merge_with_defaults(defaults, overrides.clone()); Self::validate_merged(&merged)?; // Write overrides directly to file let content = serde_json::to_string_pretty(&overrides)?; fs::write(&profiles_path, content)?; tracing::info!("Saved profile overrides to {:?}", profiles_path); Ok(()) } /// Deep merge defaults with user overrides fn merge_with_defaults(mut defaults: Self, overrides: Self) -> Self { for (executor_key, override_profile) in overrides.executors { match defaults.executors.get_mut(&executor_key) { Some(default_profile) => { // Merge configurations (user configs override defaults, new ones are added) for (config_name, config) in override_profile.configurations { default_profile.configurations.insert(config_name, config); } if override_profile.recently_used_models.is_some() { default_profile.recently_used_models = override_profile.recently_used_models; } } None => { // New executor, add completely defaults.executors.insert(executor_key, override_profile); } } } defaults } /// Compute what overrides are needed to transform defaults into current config fn compute_overrides(defaults: &Self, current: &Self) -> Result { let mut overrides = Self { executors: HashMap::new(), }; // Fast scan for any illegal deletions BEFORE allocating/cloning for (executor_key, default_profile) in &defaults.executors { // Check if executor was removed entirely if !current.executors.contains_key(executor_key) { return Err(ProfileError::CannotDeleteExecutor { executor: *executor_key, }); } let current_profile = ¤t.executors[executor_key]; // Check if ANY built-in configuration was removed for config_name in default_profile.configurations.keys() { if !current_profile.configurations.contains_key(config_name) { return Err(ProfileError::CannotDeleteBuiltInConfig { executor: *executor_key, variant: config_name.clone(), }); } } } for (executor_key, current_profile) in ¤t.executors { if let Some(default_profile) = defaults.executors.get(executor_key) { let mut override_configurations = HashMap::new(); // Check each configuration in current profile for (config_name, current_config) in ¤t_profile.configurations { if let Some(default_config) = default_profile.configurations.get(config_name) { // Only include if different from default if current_config != default_config { override_configurations .insert(config_name.clone(), current_config.clone()); } } else { // New configuration, always include override_configurations.insert(config_name.clone(), current_config.clone()); } } let mut override_profile = ExecutorProfile { recently_used_models: None, configurations: override_configurations, }; if current_profile.recently_used_models != default_profile.recently_used_models { override_profile.recently_used_models = current_profile .recently_used_models .clone() .or_else(|| Some(ExecutorRecentModels::default())); } if !override_profile.configurations.is_empty() || override_profile.recently_used_models.is_some() { overrides.executors.insert(*executor_key, override_profile); } } else { // New executor, include completely overrides .executors .insert(*executor_key, current_profile.clone()); } } Ok(overrides) } /// Validate that merged profiles are consistent and valid fn validate_merged(merged: &Self) -> Result<(), ProfileError> { for (executor_key, profile) in &merged.executors { // Ensure default configuration exists let default_config = profile.configurations.get("DEFAULT").ok_or_else(|| { ProfileError::Validation(format!( "Executor '{executor_key}' is missing required 'default' configuration" )) })?; // Validate that the default agent type matches the executor key if BaseCodingAgent::from(default_config) != *executor_key { return Err(ProfileError::Validation(format!( "Executor key '{executor_key}' does not match the agent variant '{default_config}'" ))); } // Ensure configuration names don't conflict with reserved words for config_name in profile.configurations.keys() { if config_name.starts_with("__") { return Err(ProfileError::Validation(format!( "Configuration name '{config_name}' is reserved (starts with '__')" ))); } } } Ok(()) } /// Load from the new v3 defaults pub fn from_defaults() -> Self { serde_json::from_str(DEFAULT_PROFILES_JSON).unwrap_or_else(|e| { tracing::error!("Failed to parse embedded default_profiles.json: {}", e); panic!("Default profiles v3 JSON is invalid") }) } pub fn get_coding_agent(&self, executor_profile_id: &ExecutorProfileId) -> Option { self.executors .get(&executor_profile_id.executor) .and_then(|executor| { executor.get_variant( &executor_profile_id .variant .clone() .unwrap_or("DEFAULT".to_string()), ) }) .cloned() } pub fn get_coding_agent_or_default( &self, executor_profile_id: &ExecutorProfileId, ) -> CodingAgent { self.get_coding_agent(executor_profile_id) .unwrap_or_else(|| { let mut default_executor_profile_id = executor_profile_id.clone(); default_executor_profile_id.variant = Some("DEFAULT".to_string()); self.get_coding_agent(&default_executor_profile_id) .expect("No default variant found") }) } pub async fn get_recommended_executor_profile( &self, ) -> Result { let mut agents_with_info: Vec<(BaseCodingAgent, AvailabilityInfo)> = Vec::new(); for &base_agent in self.executors.keys() { let profile_id = ExecutorProfileId::new(base_agent); if let Some(coding_agent) = self.get_coding_agent(&profile_id) { let info = coding_agent.get_availability_info(); if info.is_available() { agents_with_info.push((base_agent, info)); } } } if agents_with_info.is_empty() { return Err(ProfileError::NoAvailableExecutorProfile); } agents_with_info.sort_by(|a, b| { use crate::executors::AvailabilityInfo; match (&a.1, &b.1) { // Both have login detected - compare timestamps (most recent first) ( AvailabilityInfo::LoginDetected { last_auth_timestamp: time_a, }, AvailabilityInfo::LoginDetected { last_auth_timestamp: time_b, }, ) => time_b.cmp(time_a), // LoginDetected > InstallationFound (AvailabilityInfo::LoginDetected { .. }, AvailabilityInfo::InstallationFound) => { std::cmp::Ordering::Less } (AvailabilityInfo::InstallationFound, AvailabilityInfo::LoginDetected { .. }) => { std::cmp::Ordering::Greater } // LoginDetected > NotFound (AvailabilityInfo::LoginDetected { .. }, AvailabilityInfo::NotFound) => { std::cmp::Ordering::Less } (AvailabilityInfo::NotFound, AvailabilityInfo::LoginDetected { .. }) => { std::cmp::Ordering::Greater } // InstallationFound > NotFound (AvailabilityInfo::InstallationFound, AvailabilityInfo::NotFound) => { std::cmp::Ordering::Less } (AvailabilityInfo::NotFound, AvailabilityInfo::InstallationFound) => { std::cmp::Ordering::Greater } // Same state - equal _ => std::cmp::Ordering::Equal, } }); let selected = agents_with_info[0].0; tracing::info!("Recommended executor: {}", selected); Ok(ExecutorProfileId::new(selected)) } } pub fn to_default_variant(id: &ExecutorProfileId) -> ExecutorProfileId { ExecutorProfileId { executor: id.executor, variant: None, } } ================================================ FILE: crates/executors/src/stdout_dup.rs ================================================ //! Cross-platform stdout duplication utility for child processes //! //! Provides a single function to duplicate a child process's stdout stream. //! Supports Unix and Windows platforms. #[cfg(unix)] use std::os::unix::io::{FromRawFd, IntoRawFd, OwnedFd}; #[cfg(windows)] use std::os::windows::io::{FromRawHandle, IntoRawHandle, OwnedHandle}; use command_group::AsyncGroupChild; use futures::{StreamExt, stream::BoxStream}; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_util::io::ReaderStream; use workspace_utils::command_ext::GroupSpawnNoWindowExt; use crate::executors::{ExecutorError, SpawnedChild}; /// Duplicate stdout from AsyncGroupChild. /// /// Creates a stream that mirrors stdout of child process without consuming it. /// /// # Returns /// A stream of `io::Result` that receives a copy of all stdout data. pub fn duplicate_stdout( child: &mut AsyncGroupChild, ) -> Result>, ExecutorError> { // The implementation strategy is: // 1. create a new file descriptor. // 2. read the original stdout file descriptor. // 3. write the data to both the new file descriptor and a duplicate stream. // Take the original stdout let original_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, "Child process has no stdout", )) })?; // Create a new file descriptor in a cross-platform way (using os_pipe crate) let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| { ExecutorError::Io(std::io::Error::other(format!("Failed to create pipe: {e}"))) })?; // Use fd as new child stdout child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?); // Obtain writer from fd let mut fd_writer = wrap_fd_as_tokio_writer(pipe_writer)?; // Create the duplicate stdout stream let (dup_writer, dup_reader) = tokio::sync::mpsc::unbounded_channel::>(); // Read original stdout and write to both new ChildStdout and duplicate stream tokio::spawn(async move { let mut stdout_stream = ReaderStream::new(original_stdout); while let Some(res) = stdout_stream.next().await { match res { Ok(data) => { let _ = fd_writer.write_all(&data).await; let string_chunk = String::from_utf8_lossy(&data).into_owned(); let _ = dup_writer.send(Ok(string_chunk)); } Err(err) => { tracing::error!("Error reading from child stdout: {}", err); let _ = dup_writer.send(Err(err)); } } } }); // Return the channel receiver as a boxed stream Ok(Box::pin(UnboundedReceiverStream::new(dup_reader))) } /// Handle to append additional lines into the child's stdout stream. #[derive(Clone)] pub struct StdoutAppender { tx: tokio::sync::mpsc::UnboundedSender, } impl StdoutAppender { pub fn append_line>(&self, line: S) { // Best-effort; ignore send errors if writer task ended let mut line = line.into(); while line.ends_with('\n') || line.ends_with('\r') { line.pop(); } let _ = self.tx.send(line); } } /// Tee the child's stdout and provide both a duplicate stream and an appender to write additional /// lines into the child's stdout. This keeps the original stdout functional and mirrors output to /// the returned duplicate stream. pub fn tee_stdout_with_appender( child: &mut AsyncGroupChild, ) -> Result<(BoxStream<'static, std::io::Result>, StdoutAppender), ExecutorError> { // Take original stdout let original_stdout = child.inner().stdout.take().ok_or_else(|| { ExecutorError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, "Child process has no stdout", )) })?; // Create replacement pipe and set as new child stdout let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| { ExecutorError::Io(std::io::Error::other(format!("Failed to create pipe: {e}"))) })?; child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?); // Single shared writer for both original stdout forwarding and injected lines let writer = wrap_fd_as_tokio_writer(pipe_writer)?; let shared_writer = std::sync::Arc::new(tokio::sync::Mutex::new(writer)); // Create duplicate stream publisher let (dup_tx, dup_rx) = tokio::sync::mpsc::unbounded_channel::>(); // Create injector channel let (inj_tx, mut inj_rx) = tokio::sync::mpsc::unbounded_channel::(); // Task 1: forward original stdout to child stdout and duplicate stream { let shared_writer = shared_writer.clone(); tokio::spawn(async move { let mut stdout_stream = ReaderStream::new(original_stdout); while let Some(res) = stdout_stream.next().await { match res { Ok(data) => { // forward to child stdout let mut w = shared_writer.lock().await; let _ = w.write_all(&data).await; // publish duplicate let string_chunk = String::from_utf8_lossy(&data).into_owned(); let _ = dup_tx.send(Ok(string_chunk)); } Err(err) => { let _ = dup_tx.send(Err(err)); } } } }); } // Task 2: write injected lines to child stdout { let shared_writer = shared_writer.clone(); tokio::spawn(async move { while let Some(line) = inj_rx.recv().await { let mut data = line.into_bytes(); data.push(b'\n'); let mut w = shared_writer.lock().await; let _ = w.write_all(&data).await; } }); } Ok(( Box::pin(UnboundedReceiverStream::new(dup_rx)), StdoutAppender { tx: inj_tx }, )) } /// Create a fresh stdout pipe for the child process and return an async writer /// that writes directly to the child's new stdout. /// /// This helper does not read or duplicate any existing stdout; it simply /// replaces the child's stdout with a new pipe reader and returns the /// corresponding async writer for the caller to write into. pub fn create_stdout_pipe_writer<'b>( child: &mut AsyncGroupChild, ) -> Result { // Create replacement pipe and set as new child stdout let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| { ExecutorError::Io(std::io::Error::other(format!("Failed to create pipe: {e}"))) })?; child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?); // Return async writer to the caller wrap_fd_as_tokio_writer(pipe_writer) } /// Create a helper child process to be used only for stdout duplication. pub fn spawn_local_output_process() -> Result<(SpawnedChild, impl AsyncWrite + Send + Unpin), ExecutorError> { let (pipe_reader, pipe_writer) = os_pipe::pipe().map_err(|e| { ExecutorError::Io(std::io::Error::other(format!( "Failed to create stdout pipe: {e}" ))) })?; #[cfg(unix)] let mut cmd = { let mut cmd = tokio::process::Command::new("/bin/sh"); cmd.args(["-c", "while :; do sleep 3600; done"]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); cmd }; #[cfg(windows)] let mut cmd = { let mut cmd = tokio::process::Command::new("powershell.exe"); cmd.args([ "-NoLogo", "-NonInteractive", "-Command", "[System.Threading.Thread]::Sleep([int]::MaxValue)", ]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); cmd }; cmd.kill_on_drop(true); let mut child = cmd.group_spawn_no_window()?; // Replace stdout with our pipe child.inner().stdout = Some(wrap_fd_as_child_stdout(pipe_reader)?); let writer = wrap_fd_as_tokio_writer(pipe_writer)?; let spawned = SpawnedChild { child, exit_signal: None, cancel: None, }; Ok((spawned, writer)) } // ========================================= // OS file descriptor helper functions // ========================================= /// Convert os_pipe::PipeReader to tokio::process::ChildStdout fn wrap_fd_as_child_stdout( pipe_reader: os_pipe::PipeReader, ) -> Result { #[cfg(unix)] { // On Unix: PipeReader -> raw fd -> OwnedFd -> std::process::ChildStdout -> tokio::process::ChildStdout let raw_fd = pipe_reader.into_raw_fd(); let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; let std_stdout = std::process::ChildStdout::from(owned_fd); tokio::process::ChildStdout::from_std(std_stdout).map_err(ExecutorError::Io) } #[cfg(windows)] { // On Windows: PipeReader -> raw handle -> OwnedHandle -> std::process::ChildStdout -> tokio::process::ChildStdout let raw_handle = pipe_reader.into_raw_handle(); let owned_handle = unsafe { OwnedHandle::from_raw_handle(raw_handle) }; let std_stdout = std::process::ChildStdout::from(owned_handle); tokio::process::ChildStdout::from_std(std_stdout).map_err(ExecutorError::Io) } } /// Convert os_pipe::PipeWriter to a tokio file for async writing fn wrap_fd_as_tokio_writer( pipe_writer: os_pipe::PipeWriter, ) -> Result { #[cfg(unix)] { // On Unix: PipeWriter -> raw fd -> OwnedFd -> std::fs::File -> tokio::fs::File let raw_fd = pipe_writer.into_raw_fd(); let owned_fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; let std_file = std::fs::File::from(owned_fd); Ok(tokio::fs::File::from_std(std_file)) } #[cfg(windows)] { // On Windows: PipeWriter -> raw handle -> OwnedHandle -> std::fs::File -> tokio::fs::File let raw_handle = pipe_writer.into_raw_handle(); let owned_handle = unsafe { OwnedHandle::from_raw_handle(raw_handle) }; let std_file = std::fs::File::from(owned_handle); Ok(tokio::fs::File::from_std(std_file)) } } ================================================ FILE: crates/git/Cargo.toml ================================================ [package] name = "git" version = "0.1.33" edition = "2024" [features] default = [] cloud = [] [dependencies] chrono = { version = "0.4", features = ["serde"] } dirs = "5.0" git2 = { workspace = true } serde = { workspace = true } tempfile = "3.21" thiserror = { workspace = true } tracing = { workspace = true } ts-rs = { workspace = true } utils = { path = "../utils" } [dev-dependencies] tempfile = "3.21" ================================================ FILE: crates/git/src/cli.rs ================================================ //! Why we prefer the Git CLI here //! //! - Safer working-tree semantics: the `git` CLI refuses to clobber uncommitted //! tracked changes and untracked files during checkout/merge/rebase unless you //! explicitly force it. libgit2 does not enforce those protections by default, //! which means callers must re‑implement a lot of safety checks to avoid data loss. //! - Sparse‑checkout correctness: the CLI natively respects sparse‑checkout. //! libgit2 does not yet support sparse‑checkout semantics the same way, which //! led to incorrect diffs and staging in our workflows. //! - Cross‑platform stability: we observed libgit2 corrupt repositories shared //! between WSL and Windows in scenarios where the `git` CLI did not. Delegating //! working‑tree mutations to the CLI has proven more reliable in practice. //! //! Given these reasons, this module centralizes destructive or working‑tree‑ //! touching operations (rebase, merge, checkout, add/commit, etc.) through the //! `git` CLI, while keeping libgit2 for read‑only graph queries and credentialed //! network operations when useful. use std::{ ffi::{OsStr, OsString}, io::Write as _, path::Path, process::{Command, Stdio}, }; use thiserror::Error; use utils::{path::ALWAYS_SKIP_DIRS, shell::resolve_executable_path_blocking}; use super::Commit; #[derive(Debug, Error)] pub enum GitCliError { #[error("git executable not found or not runnable")] NotAvailable, #[error("git command failed: {0}")] CommandFailed(String), #[error("authentication failed: {0}")] AuthFailed(String), #[error("push rejected: {0}")] PushRejected(String), #[error("rebase in progress in this worktree")] RebaseInProgress, } #[derive(Clone, Default)] pub struct GitCli; /// Parsed change type from `git diff --name-status` output #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChangeType { Added, Modified, Deleted, Renamed, Copied, TypeChanged, Unmerged, Unknown(String), } /// One entry from a status diff (name-status + paths) #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusDiffEntry { pub change: ChangeType, pub path: String, pub old_path: Option, } /// Parsed worktree entry from `git worktree list --porcelain` #[derive(Debug, Clone)] pub struct WorktreeEntry { pub path: String, pub branch: Option, } #[derive(Debug, Clone, Default)] pub struct StatusDiffOptions { pub path_filter: Option>, // pathspecs to limit diff } impl GitCli { pub fn new() -> Self { Self {} } /// Run `git -C worktree add ` (optionally creating the branch with -b) pub fn worktree_add( &self, repo_path: &Path, worktree_path: &Path, branch: &str, create_branch: bool, ) -> Result<(), GitCliError> { self.ensure_available()?; let mut args: Vec = vec!["worktree".into(), "add".into()]; if create_branch { args.push("-b".into()); args.push(OsString::from(branch)); } args.push(worktree_path.as_os_str().into()); args.push(OsString::from(branch)); self.git(repo_path, args)?; // Good practice: reapply sparse-checkout in the new worktree to ensure materialization matches // Non-fatal if it fails or not configured. let _ = self.git(worktree_path, ["sparse-checkout", "reapply"]); Ok(()) } /// Run `git -C worktree remove ` pub fn worktree_remove( &self, repo_path: &Path, worktree_path: &Path, force: bool, ) -> Result<(), GitCliError> { self.ensure_available()?; let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); } args.push(worktree_path.as_os_str().into()); self.git(repo_path, args)?; Ok(()) } /// Run `git -C worktree move ` pub fn worktree_move( &self, repo_path: &Path, old_path: &Path, new_path: &Path, ) -> Result<(), GitCliError> { self.ensure_available()?; self.git( repo_path, [ "worktree", "move", old_path.to_str().ok_or_else(|| { GitCliError::CommandFailed("Invalid old worktree path".to_string()) })?, new_path.to_str().ok_or_else(|| { GitCliError::CommandFailed("Invalid new worktree path".to_string()) })?, ], )?; Ok(()) } /// Prune stale worktree metadata pub fn worktree_prune(&self, repo_path: &Path) -> Result<(), GitCliError> { self.git(repo_path, ["worktree", "prune"])?; Ok(()) } /// Return true if there are any changes in the working tree (staged or unstaged). pub fn has_changes(&self, worktree_path: &Path) -> Result { let out = self.git( worktree_path, ["--no-optional-locks", "status", "--porcelain"], )?; Ok(!out.is_empty()) } /// Diff status vs a base branch using a temporary index (always includes untracked). /// Path filter limits the reported paths. pub fn diff_status( &self, worktree_path: &Path, base_commit: &Commit, opts: StatusDiffOptions, ) -> Result, GitCliError> { // Create a temp index file let tmp_dir = tempfile::TempDir::new() .map_err(|e| GitCliError::CommandFailed(format!("temp dir create failed: {e}")))?; let tmp_index = tmp_dir.path().join("index"); let envs = vec![( OsString::from("GIT_INDEX_FILE"), tmp_index.as_os_str().to_os_string(), )]; // Use a temp index from HEAD to accurately track renames in untracked files let _ = self.git_with_env(worktree_path, ["read-tree", "HEAD"], &envs)?; // Stage changed and untracked files explicitly, which is faster than `git add -A` for large repos. // Use raw paths from `get_worktree_status` to avoid lossy UTF-8 conversions for odd filenames. let status = self.get_worktree_status(worktree_path)?; let mut paths_to_add: Vec> = Vec::new(); for entry in status.entries { paths_to_add.push(entry.path); if let Some(orig) = entry.orig_path { paths_to_add.push(orig); } } if !paths_to_add.is_empty() { paths_to_add.extend( Self::get_default_pathspec_excludes() .iter() .map(|s| s.as_encoded_bytes().to_vec()), ); let mut input = Vec::new(); for p in paths_to_add { input.extend_from_slice(&p); input.push(0); } let args = vec![ OsString::from("add"), OsString::from("-A"), OsString::from("--pathspec-from-file=-"), OsString::from("--pathspec-file-nul"), ]; self.git_with_stdin(worktree_path, args, Some(&envs), &input)?; } // git diff --cached let mut args: Vec = vec![ "-c".into(), "core.quotepath=false".into(), "diff".into(), "--cached".into(), "-M".into(), "--name-status".into(), OsString::from(base_commit.to_string()), ]; args = Self::apply_pathspec_filter(args, opts.path_filter.as_ref()); let out = self.git_with_env(worktree_path, args, &envs)?; Ok(Self::parse_name_status(&out)) } /// Return `git status --porcelain` parsed into a structured summary pub fn get_worktree_status(&self, worktree_path: &Path) -> Result { // Using -z for NUL-separated output which correctly handles paths with special chars. // Format: XYPATH[ORIGPATH] where ORIGPATH only present for R/C. let args = Self::apply_default_excludes(vec![ "--no-optional-locks", "status", "--porcelain", "-z", "--untracked-files=normal", ]); let out = self.git_impl(worktree_path, args, None, None)?; let mut entries = Vec::new(); let mut uncommitted_tracked = 0usize; let mut untracked = 0usize; let mut parts = out.split(|b| *b == 0); while let Some(part) = parts.next() { if part.is_empty() || part.len() < 4 { continue; } let staged = part[0] as char; let unstaged = part[1] as char; let path = part[3..].to_vec(); let mut orig_path = None; if (staged == 'R' || unstaged == 'R' || staged == 'C' || unstaged == 'C') && let Some(old_path) = parts.next() && !old_path.is_empty() { orig_path = Some(old_path.to_vec()); } if staged == '?' && unstaged == '?' { untracked += 1; entries.push(StatusEntry { staged, unstaged, path, orig_path, is_untracked: true, }); } else { if staged != ' ' || unstaged != ' ' { uncommitted_tracked += 1; } entries.push(StatusEntry { staged, unstaged, path, orig_path, is_untracked: false, }); } } Ok(WorktreeStatus { uncommitted_tracked, untracked, entries, }) } /// Stage all changes in the working tree (respects sparse-checkout semantics). pub fn add_all(&self, worktree_path: &Path) -> Result<(), GitCliError> { self.git( worktree_path, Self::apply_default_excludes(vec!["add", "-A"]), )?; Ok(()) } pub fn list_worktrees(&self, repo_path: &Path) -> Result, GitCliError> { let out = self.git(repo_path, ["worktree", "list", "--porcelain"])?; let mut entries = Vec::new(); let mut current_path: Option = None; let mut current_head: Option = None; let mut current_branch: Option = None; for line in out.lines() { let line = line.trim(); if line.is_empty() { // End of current worktree entry, save it if we have required data if let (Some(path), Some(_head)) = (current_path.take(), current_head.take()) { entries.push(WorktreeEntry { path, branch: current_branch.take(), }); } } else if let Some(path) = line.strip_prefix("worktree ") { current_path = Some(path.to_string()); } else if let Some(head) = line.strip_prefix("HEAD ") { current_head = Some(head.to_string()); } else if let Some(branch_ref) = line.strip_prefix("branch ") { // Extract branch name from refs/heads/branch-name current_branch = branch_ref .strip_prefix("refs/heads/") .map(|name| name.to_string()); } } // Handle the last entry if no trailing empty line if let (Some(path), Some(_head)) = (current_path, current_head) { entries.push(WorktreeEntry { path, branch: current_branch, }); } Ok(entries) } /// Commit staged changes with the given message. pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<(), GitCliError> { self.git(worktree_path, ["commit", "-m", message])?; Ok(()) } /// Fetch a branch to the given remote using native git authentication. pub fn fetch_with_refspec( &self, repo_path: &Path, remote_url: &str, refspec: &str, ) -> Result<(), GitCliError> { let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ OsString::from("fetch"), OsString::from(remote_url), OsString::from(refspec), ]; match self.git_with_env(repo_path, args, &envs) { Ok(_) => Ok(()), Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)), Err(err) => Err(err), } } /// Push a branch to the given remote using native git authentication. pub fn push( &self, repo_path: &Path, remote_url: &str, branch: &str, force: bool, ) -> Result<(), GitCliError> { let refspec = if force { format!("+refs/heads/{branch}:refs/heads/{branch}") } else { format!("refs/heads/{branch}:refs/heads/{branch}") }; let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ OsString::from("push"), OsString::from(remote_url), OsString::from(refspec), ]; match self.git_with_env(repo_path, args, &envs) { Ok(_) => Ok(()), Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)), Err(err) => Err(err), } } /// This directly queries the remote without fetching. pub fn check_remote_branch_exists( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result { let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ OsString::from("ls-remote"), OsString::from("--heads"), OsString::from(remote_url), OsString::from(format!("refs/heads/{branch_name}")), ]; match self.git_with_env(repo_path, args, &envs) { Ok(output) => Ok(!output.trim().is_empty()), Err(GitCliError::CommandFailed(msg)) => Err(self.classify_cli_error(msg)), Err(err) => Err(err), } } /// Delete a local branch from the repository (force delete). pub fn delete_branch(&self, repo_path: &Path, branch_name: &str) -> Result<(), GitCliError> { self.ensure_available()?; self.git(repo_path, ["branch", "-D", branch_name])?; Ok(()) } pub fn get_remote_url( &self, repo_path: &Path, remote_name: &str, ) -> Result { let output = self.git(repo_path, ["remote", "get-url", remote_name])?; Ok(output.trim().to_string()) } /// List all remotes with their URLs using `git remote -v`. /// Returns a Vec of (name, url) tuples, deduplicated (fetch/push show the same URL). pub fn list_remotes(&self, repo_path: &Path) -> Result, GitCliError> { let output = self.git(repo_path, ["remote", "-v"])?; let mut seen = std::collections::HashSet::new(); let mut remotes = Vec::new(); for line in output.lines() { let line = line.trim(); if line.is_empty() { continue; } // Format: "name\turl (fetch)" or "name\turl (push)" let parts: Vec<&str> = line.split('\t').collect(); if parts.len() >= 2 { let name = parts[0].to_string(); // Remove the " (fetch)" or " (push)" suffix from URL let url = parts[1] .strip_suffix(" (fetch)") .or_else(|| parts[1].strip_suffix(" (push)")) .unwrap_or(parts[1]) .to_string(); if seen.insert(name.clone()) { remotes.push((name, url)); } } } Ok(remotes) } // Parse `git diff --name-status` output into structured entries. // Handles rename/copy scores like `R100` by matching the first letter. fn parse_name_status(output: &str) -> Vec { let mut out = Vec::new(); for line in output.lines() { let line = line.trim_end(); if line.is_empty() { continue; } let mut parts = line.split('\t'); let code = parts.next().unwrap_or(""); let change = match code.chars().next().unwrap_or('?') { 'A' => ChangeType::Added, 'M' => ChangeType::Modified, 'D' => ChangeType::Deleted, 'R' => ChangeType::Renamed, 'C' => ChangeType::Copied, 'T' => ChangeType::TypeChanged, 'U' => ChangeType::Unmerged, other => ChangeType::Unknown(other.to_string()), }; match change { ChangeType::Renamed | ChangeType::Copied => { if let (Some(old), Some(newp)) = (parts.next(), parts.next()) { out.push(StatusDiffEntry { change, path: newp.to_string(), old_path: Some(old.to_string()), }); } } _ => { if let Some(p) = parts.next() { out.push(StatusDiffEntry { change, path: p.to_string(), old_path: None, }); } } } } out } /// Return the merge base commit sha of two refs in the given worktree. /// If `git merge-base --fork-point` fails, falls back to regular `merge-base`. pub fn merge_base( &self, worktree_path: &Path, a: &str, b: &str, ) -> Result { let out = self .git(worktree_path, ["merge-base", "--fork-point", a, b]) .unwrap_or(self.git(worktree_path, ["merge-base", a, b])?); Ok(out.trim().to_string()) } /// Perform `git rebase --onto ` on in `worktree_path`. pub fn rebase_onto( &self, worktree_path: &Path, new_base: &str, old_base: &str, task_branch: &str, ) -> Result<(), GitCliError> { // If a rebase is in progress, refuse to proceed. The caller can // choose to abort or continue; we avoid destructive actions here. if self.is_rebase_in_progress(worktree_path).unwrap_or(false) { return Err(GitCliError::RebaseInProgress); } // compute the merge base of task_branch from old_base let merge_base = self .merge_base(worktree_path, old_base, task_branch) .unwrap_or(old_base.to_string()); self.git( worktree_path, ["rebase", "--onto", new_base, &merge_base, task_branch], )?; Ok(()) } /// Return true if there is a rebase in progress in this worktree. /// We treat this as true when either of Git's rebase state directories exists: /// - rebase-merge (interactive rebase) /// - rebase-apply (am-based rebase) pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result { let rebase_merge = self.git(worktree_path, ["rev-parse", "--git-path", "rebase-merge"])?; let rebase_apply = self.git(worktree_path, ["rev-parse", "--git-path", "rebase-apply"])?; let rm_exists = std::path::Path::new(rebase_merge.trim()).exists(); let ra_exists = std::path::Path::new(rebase_apply.trim()).exists(); Ok(rm_exists || ra_exists) } /// Return true if a merge is in progress (MERGE_HEAD exists). pub fn is_merge_in_progress(&self, worktree_path: &Path) -> Result { match self.git(worktree_path, ["rev-parse", "--verify", "MERGE_HEAD"]) { Ok(_) => Ok(true), Err(GitCliError::CommandFailed(_)) => Ok(false), Err(e) => Err(e), } } /// Return true if a cherry-pick is in progress (CHERRY_PICK_HEAD exists). pub fn is_cherry_pick_in_progress(&self, worktree_path: &Path) -> Result { match self.git(worktree_path, ["rev-parse", "--verify", "CHERRY_PICK_HEAD"]) { Ok(_) => Ok(true), Err(GitCliError::CommandFailed(_)) => Ok(false), Err(e) => Err(e), } } /// Return true if a revert is in progress (REVERT_HEAD exists). pub fn is_revert_in_progress(&self, worktree_path: &Path) -> Result { match self.git(worktree_path, ["rev-parse", "--verify", "REVERT_HEAD"]) { Ok(_) => Ok(true), Err(GitCliError::CommandFailed(_)) => Ok(false), Err(e) => Err(e), } } /// Abort an in-progress rebase in this worktree. If no rebase is in progress, /// this is a no-op and returns Ok(()). pub fn abort_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> { // If nothing to abort, return success if !self.is_rebase_in_progress(worktree_path)? { return Ok(()); } // Best-effort: if `git rebase --abort` fails, surface the error message self.git(worktree_path, ["rebase", "--abort"]).map(|_| ()) } /// Quit an in-progress rebase (cleanup metadata without modifying commits). /// If no rebase is in progress, it's a no-op. pub fn quit_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> { if !self.is_rebase_in_progress(worktree_path)? { return Ok(()); } self.git(worktree_path, ["rebase", "--quit"]).map(|_| ()) } /// Continue an in-progress rebase. Returns error if no rebase is in progress /// or if there are unresolved conflicts. pub fn continue_rebase(&self, worktree_path: &Path) -> Result<(), GitCliError> { if !self.is_rebase_in_progress(worktree_path)? { return Err(GitCliError::CommandFailed( "No rebase in progress".to_string(), )); } self.git(worktree_path, ["rebase", "--continue"]) .map(|_| ()) } /// Return true if there are staged changes (index differs from HEAD) pub fn has_staged_changes(&self, repo_path: &Path) -> Result { use utils::command_ext::NoWindowExt; // `git diff --cached --quiet` returns exit code 1 if there are differences let out = Command::new(resolve_executable_path_blocking("git").ok_or(GitCliError::NotAvailable)?) .arg("-C") .arg(repo_path) .arg("diff") .arg("--cached") .arg("--quiet") .no_window() .output() .map_err(|e| GitCliError::CommandFailed(e.to_string()))?; match out.status.code() { Some(0) => Ok(false), Some(1) => Ok(true), _ => Err(GitCliError::CommandFailed( String::from_utf8_lossy(&out.stderr).trim().to_string(), )), } } /// Checkout base branch, squash-merge from_branch, and commit with message. Returns new HEAD sha. pub fn merge_squash_commit( &self, repo_path: &Path, base_branch: &str, from_branch: &str, message: &str, ) -> Result { self.git(repo_path, ["checkout", base_branch]).map(|_| ())?; self.git(repo_path, ["merge", "--squash", "--no-commit", from_branch]) .map(|_| ())?; self.git(repo_path, ["commit", "-m", message]).map(|_| ())?; let sha = self .git(repo_path, ["rev-parse", "HEAD"])? .trim() .to_string(); Ok(sha) } /// Update a ref to a specific sha in the repo. pub fn update_ref( &self, repo_path: &Path, refname: &str, sha: &str, ) -> Result<(), GitCliError> { self.git(repo_path, ["update-ref", refname, sha]) .map(|_| ()) } pub fn abort_merge(&self, worktree_path: &Path) -> Result<(), GitCliError> { if !self.is_merge_in_progress(worktree_path)? { return Ok(()); } self.git(worktree_path, ["merge", "--abort"]).map(|_| ()) } pub fn abort_cherry_pick(&self, worktree_path: &Path) -> Result<(), GitCliError> { if !self.is_cherry_pick_in_progress(worktree_path)? { return Ok(()); } self.git(worktree_path, ["cherry-pick", "--abort"]) .map(|_| ()) } pub fn abort_revert(&self, worktree_path: &Path) -> Result<(), GitCliError> { if !self.is_revert_in_progress(worktree_path)? { return Ok(()); } self.git(worktree_path, ["revert", "--abort"]).map(|_| ()) } /// List files currently in a conflicted (unmerged) state in the worktree. pub fn get_conflicted_files(&self, worktree_path: &Path) -> Result, GitCliError> { // `--diff-filter=U` lists paths with unresolved conflicts let out = self.git(worktree_path, ["diff", "--name-only", "--diff-filter=U"])?; let mut files = Vec::new(); for line in out.lines() { let p = line.trim(); if !p.is_empty() { files.push(p.to_string()); } } Ok(files) } } // Private methods impl GitCli { fn classify_cli_error(&self, msg: String) -> GitCliError { let lower = msg.to_ascii_lowercase(); if lower.contains("authentication failed") || lower.contains("could not read username") || lower.contains("invalid username or password") { GitCliError::AuthFailed(msg) } else if lower.contains("non-fast-forward") || lower.contains("failed to push some refs") || lower.contains("fetch first") || lower.contains("updates were rejected because the tip") { GitCliError::PushRejected(msg) } else { GitCliError::CommandFailed(msg) } } /// Ensure `git` is available on PATH fn ensure_available(&self) -> Result<(), GitCliError> { use utils::command_ext::NoWindowExt; let git = resolve_executable_path_blocking("git").ok_or(GitCliError::NotAvailable)?; let out = Command::new(&git) .arg("--version") .no_window() .output() .map_err(|_| GitCliError::NotAvailable)?; if out.status.success() { Ok(()) } else { Err(GitCliError::NotAvailable) } } /// Run `git -C ` and return stdout bytes on success. /// Prefer adding specific helpers (e.g. `get_worktree_status`, `diff_status`) /// instead of calling this directly, so all parsing and command choices are /// centralized here. This makes it easier to change the underlying commands /// without adjusting callers. Use this low-level method directly only in /// tests or when no dedicated helper exists yet. /// /// About `OsStr`/`OsString` usage: /// - `Command` and `Path` operate on `OsStr` to support non‑UTF‑8 paths and /// arguments across platforms. Using `String` would force lossy conversion /// or partial failures. This API accepts anything that implements /// `AsRef` so typical call sites can still pass `&str` literals or /// owned `String`s without friction. fn git_impl( &self, repo_path: &Path, args: I, envs: Option<&[(OsString, OsString)]>, stdin: Option<&[u8]>, ) -> Result, GitCliError> where I: IntoIterator, S: AsRef, { self.ensure_available()?; let git = resolve_executable_path_blocking("git").ok_or(GitCliError::NotAvailable)?; let mut cmd = Command::new(&git); cmd.arg("-C").arg(repo_path); if let Some(envs) = envs { for (k, v) in envs { cmd.env(k, v); } } for a in args { cmd.arg(a); } if stdin.is_some() { cmd.stdin(Stdio::piped()); } else { cmd.stdin(Stdio::null()); } cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); tracing::trace!( stdin = ?stdin.as_ref().map(|s| String::from_utf8_lossy(s)), repo = ?repo_path, "Running git command: {:?}", cmd ); use utils::command_ext::NoWindowExt; let mut child = cmd .no_window() .spawn() .map_err(|e| GitCliError::CommandFailed(e.to_string()))?; let stdin_write_result = if let Some(input) = stdin && let Some(mut child_stdin) = child.stdin.take() { Some(child_stdin.write_all(input)) } else { None }; let out = child .wait_with_output() .map_err(|e| GitCliError::CommandFailed(e.to_string()))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); let combined = match (stdout.is_empty(), stderr.is_empty()) { (true, true) => "Command failed with no output".to_string(), (false, false) => format!("--- stderr\n{stderr}\n--- stdout\n{stdout}"), (false, true) => format!("--- stderr\n{stdout}"), (true, false) => format!("--- stdout\n{stderr}"), }; return Err(GitCliError::CommandFailed(combined)); } if let Some(Err(e)) = stdin_write_result { return Err(GitCliError::CommandFailed(format!( "failed to write to git stdin: {e}" ))); } Ok(out.stdout) } pub fn git(&self, repo_path: &Path, args: I) -> Result where I: IntoIterator, S: AsRef, { let out = self.git_impl(repo_path, args, None, None)?; Ok(String::from_utf8_lossy(&out).to_string()) } fn git_with_env( &self, repo_path: &Path, args: I, envs: &[(OsString, OsString)], ) -> Result where I: IntoIterator, S: AsRef, { let out = self.git_impl(repo_path, args, Some(envs), None)?; Ok(String::from_utf8_lossy(&out).to_string()) } fn git_with_stdin( &self, repo_path: &Path, args: I, envs: Option<&[(OsString, OsString)]>, stdin: &[u8], ) -> Result where I: IntoIterator, S: AsRef, { let out = self.git_impl(repo_path, args, envs, Some(stdin))?; Ok(String::from_utf8_lossy(&out).to_string()) } fn apply_default_excludes(args: I) -> Vec where I: IntoIterator, S: AsRef, { Self::apply_pathspec_filter(args, None) } fn apply_pathspec_filter(args: I, pathspecs: Option<&Vec>) -> Vec where I: IntoIterator, S: AsRef, { let filters = Self::build_pathspec_filter(pathspecs); let mut args = args .into_iter() .map(|s| s.as_ref().to_os_string()) .collect::>(); if !filters.is_empty() { args.push("--".into()); args.extend(filters); } args } fn build_pathspec_filter(pathspecs: Option<&Vec>) -> Vec { let mut filters = Vec::new(); filters.extend(Self::get_default_pathspec_excludes()); if let Some(pathspecs) = pathspecs { for p in pathspecs { if p.trim().is_empty() { continue; } filters.push(OsString::from(p)); } } filters } fn get_default_pathspec_excludes() -> Vec { ALWAYS_SKIP_DIRS .iter() .map(|d| OsString::from(format!(":(glob,exclude)**/{d}/"))) .collect() } } /// Parsed entry from `git status --porcelain` #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusEntry { /// Single-letter staged status (column X) or '?' for untracked pub staged: char, /// Single-letter unstaged status (column Y) or '?' for untracked pub unstaged: char, /// Current path (raw bytes to avoid lossy UTF-8 conversion) pub path: Vec, /// Original path (for renames), raw bytes pub orig_path: Option>, /// True if this entry is untracked ("??") pub is_untracked: bool, } /// Summary + entries for a working tree status #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeStatus { pub uncommitted_tracked: usize, pub untracked: usize, pub entries: Vec, } ================================================ FILE: crates/git/src/lib.rs ================================================ use std::{collections::HashMap, path::Path}; use chrono::{DateTime, Utc}; use git2::{ BranchType, Delta, DiffFindOptions, DiffOptions, Error as GitError, Reference, Remote, Repository, Sort, }; use serde::{Deserialize, Serialize}; use thiserror::Error; use ts_rs::TS; use utils::diff::{Diff, DiffChangeKind, FileDiffDetails, compute_line_change_counts}; mod cli; mod validation; use cli::{ChangeType, StatusDiffEntry, StatusDiffOptions}; pub use cli::{GitCli, GitCliError, StatusEntry, WorktreeStatus}; pub use utils::path::ALWAYS_SKIP_DIRS; pub use validation::is_valid_branch_prefix; /// Statistics for a single file based on git history #[derive(Clone, Debug)] pub struct FileStat { /// Index in the commit history (0 = HEAD, 1 = parent of HEAD, ...) pub last_index: usize, /// Number of times this file was changed in recent commits pub commit_count: u32, /// Timestamp of the most recent change pub last_time: DateTime, } #[derive(Debug, Error)] pub enum GitServiceError { #[error(transparent)] Git(#[from] GitError), #[error(transparent)] GitCLI(#[from] GitCliError), #[error(transparent)] IoError(#[from] std::io::Error), #[error("Invalid repository: {0}")] InvalidRepository(String), #[error("Branch not found: {0}")] BranchNotFound(String), #[error("Merge conflicts: {message}")] MergeConflicts { message: String, conflicted_files: Vec, }, #[error("Branches diverged: {0}")] BranchesDiverged(String), #[error("{0} has uncommitted changes: {1}")] WorktreeDirty(String, String), #[error("Rebase in progress; resolve or abort it before retrying")] RebaseInProgress, } /// Service for managing Git operations in task execution workflows #[derive(Clone)] pub struct GitService {} // Max inline diff size for UI (in bytes). Files larger than this will have // their contents omitted from the diff stream to avoid UI crashes. const MAX_INLINE_DIFF_BYTES: usize = 2 * 1024 * 1024; // ~2MB #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq, Eq)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] pub enum ConflictOp { Rebase, Merge, CherryPick, Revert, } #[derive(Debug, Serialize, TS)] pub struct GitBranch { pub name: String, pub is_current: bool, pub is_remote: bool, #[ts(type = "Date")] pub last_commit_date: DateTime, } #[derive(Debug, Clone, Serialize, TS)] pub struct GitRemote { pub name: String, pub url: String, } #[derive(Debug, Clone)] pub struct HeadInfo { pub branch: String, pub oid: String, } #[derive(Debug, Clone)] pub struct Commit(git2::Oid); impl Commit { pub fn new(id: git2::Oid) -> Self { Self(id) } pub fn as_oid(&self) -> git2::Oid { self.0 } } impl std::fmt::Display for Commit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, Clone, Copy)] pub struct WorktreeResetOptions { pub perform_reset: bool, pub force_when_dirty: bool, pub is_dirty: bool, pub log_skip_when_dirty: bool, } impl WorktreeResetOptions { pub fn new( perform_reset: bool, force_when_dirty: bool, is_dirty: bool, log_skip_when_dirty: bool, ) -> Self { Self { perform_reset, force_when_dirty, is_dirty, log_skip_when_dirty, } } } #[derive(Debug, Default, Clone, Copy)] pub struct WorktreeResetOutcome { pub needed: bool, pub applied: bool, } /// Target for diff generation pub enum DiffTarget<'p> { /// Work-in-progress branch checked out in this worktree Worktree { worktree_path: &'p Path, base_commit: &'p Commit, }, /// Fully committed branch vs base branch Branch { repo_path: &'p Path, branch_name: &'p str, base_branch: &'p str, }, /// Specific commit vs base branch Commit { repo_path: &'p Path, commit_sha: &'p str, }, } impl Default for GitService { fn default() -> Self { Self::new() } } impl GitService { /// Create a new GitService for the given repository path pub fn new() -> Self { Self {} } pub fn is_branch_name_valid(&self, name: &str) -> bool { git2::Branch::name_is_valid(name).unwrap_or(false) } /// Open the repository pub fn open_repo(&self, repo_path: &Path) -> Result { Repository::open(repo_path).map_err(GitServiceError::from) } /// Ensure local (repo-scoped) identity exists for CLI commits. /// Sets user.name/email only if missing in the repo config. fn ensure_cli_commit_identity(&self, repo_path: &Path) -> Result<(), GitServiceError> { let repo = self.open_repo(repo_path)?; let cfg = repo.config()?; let has_name = cfg.get_string("user.name").is_ok(); let has_email = cfg.get_string("user.email").is_ok(); if !(has_name && has_email) { let mut cfg = repo.config()?; cfg.set_str("user.name", "Vibe Kanban")?; cfg.set_str("user.email", "noreply@vibekanban.com")?; } Ok(()) } /// Get a signature for libgit2 commits with a safe fallback identity. fn signature_with_fallback<'a>( &self, repo: &'a Repository, ) -> Result, GitServiceError> { match repo.signature() { Ok(sig) => Ok(sig), Err(_) => git2::Signature::now("Vibe Kanban", "noreply@vibekanban.com") .map_err(GitServiceError::from), } } fn default_remote( &self, repo: &Repository, repo_path: &Path, ) -> Result { let mut remotes = GitCli::new().list_remotes(repo_path)?; // Check for pushDefault config if let Ok(config) = repo.config() && let Ok(default_name) = config.get_string("remote.pushDefault") && let Some(idx) = remotes.iter().position(|(name, _)| name == &default_name) { let (name, url) = remotes.swap_remove(idx); return Ok(GitRemote { name, url }); } // Fall back to first remote remotes .into_iter() .next() .map(|(name, url)| GitRemote { name, url }) .ok_or_else(|| GitServiceError::InvalidRepository("No remotes configured".to_string())) } /// Initialize a new git repository with a main branch and initial commit pub fn initialize_repo_with_main_branch( &self, repo_path: &Path, ) -> Result<(), GitServiceError> { // Create directory if it doesn't exist if !repo_path.exists() { std::fs::create_dir_all(repo_path)?; } // Initialize git repository with main branch let repo = Repository::init_opts( repo_path, git2::RepositoryInitOptions::new() .initial_head("main") .mkdir(true), )?; // Create initial commit self.create_initial_commit(&repo)?; Ok(()) } /// Ensure an existing repository has a main branch (for empty repos) pub fn ensure_main_branch_exists(&self, repo_path: &Path) -> Result<(), GitServiceError> { let repo = self.open_repo(repo_path)?; match repo.branches(None) { Ok(branches) => { if branches.count() == 0 { // No branches exist - create initial commit on main branch self.create_initial_commit(&repo)?; } } Err(e) => { return Err(GitServiceError::InvalidRepository(format!( "Failed to list branches: {e}" ))); } } Ok(()) } pub fn create_initial_commit(&self, repo: &Repository) -> Result<(), GitServiceError> { let signature = self.signature_with_fallback(repo)?; let tree_id = { let tree_builder = repo.treebuilder(None)?; tree_builder.write()? }; let tree = repo.find_tree(tree_id)?; // Create initial commit on main branch let _commit_id = repo.commit( Some("refs/heads/main"), &signature, &signature, "Initial commit", &tree, &[], )?; // Set HEAD to point to main branch repo.set_head("refs/heads/main")?; Ok(()) } pub fn commit(&self, path: &Path, message: &str) -> Result { // Use Git CLI to respect sparse-checkout semantics for staging and commit let git = GitCli::new(); let has_changes = git .has_changes(path) .map_err(|e| GitServiceError::InvalidRepository(format!("git status failed: {e}")))?; if !has_changes { tracing::debug!("No changes to commit!"); return Ok(false); } git.add_all(path) .map_err(|e| GitServiceError::InvalidRepository(format!("git add failed: {e}")))?; // Only ensure identity once we know we're about to commit self.ensure_cli_commit_identity(path)?; git.commit(path, message) .map_err(|e| GitServiceError::InvalidRepository(format!("git commit failed: {e}")))?; Ok(true) } /// Get diffs between branches or worktree changes pub fn get_diffs( &self, target: DiffTarget, path_filter: Option<&[&str]>, ) -> Result, GitServiceError> { match target { DiffTarget::Worktree { worktree_path, base_commit, } => { // Use Git CLI to compute diff vs base to avoid sparse false deletions let repo = Repository::open(worktree_path)?; let base_tree = repo .find_commit(base_commit.as_oid())? .tree() .map_err(|e| { GitServiceError::InvalidRepository(format!( "Failed to find base commit tree: {e}" )) })?; let git = GitCli::new(); let cli_opts = StatusDiffOptions { path_filter: path_filter.map(|fs| fs.iter().map(|s| s.to_string()).collect()), }; let entries = git .diff_status(worktree_path, base_commit, cli_opts) .map_err(|e| { GitServiceError::InvalidRepository(format!("git diff failed: {e}")) })?; Ok(entries .into_iter() .map(|e| Self::status_entry_to_diff(&repo, &base_tree, e)) .collect()) } DiffTarget::Branch { repo_path, branch_name, base_branch, } => { let repo = self.open_repo(repo_path)?; let base_tree = Self::find_branch(&repo, base_branch)? .get() .peel_to_commit()? .tree()?; let branch_tree = Self::find_branch(&repo, branch_name)? .get() .peel_to_commit()? .tree()?; let mut diff_opts = DiffOptions::new(); diff_opts.include_typechange(true); // Add path filtering if specified if let Some(paths) = path_filter { for path in paths { diff_opts.pathspec(*path); } } let mut diff = repo.diff_tree_to_tree( Some(&base_tree), Some(&branch_tree), Some(&mut diff_opts), )?; // Enable rename detection let mut find_opts = DiffFindOptions::new(); diff.find_similar(Some(&mut find_opts))?; self.convert_diff_to_file_diffs(diff, &repo) } DiffTarget::Commit { repo_path, commit_sha, } => { let repo = self.open_repo(repo_path)?; // Resolve commit and its baseline (the parent before the squash landed) let commit_oid = git2::Oid::from_str(commit_sha).map_err(|_| { GitServiceError::InvalidRepository(format!("Invalid commit SHA: {commit_sha}")) })?; let commit = repo.find_commit(commit_oid)?; let parent = commit.parent(0).map_err(|_| { GitServiceError::InvalidRepository( "Commit has no parent; cannot diff a squash merge without a baseline" .into(), ) })?; let parent_tree = parent.tree()?; let commit_tree = commit.tree()?; // Diff options let mut diff_opts = git2::DiffOptions::new(); diff_opts.include_typechange(true); // Optional path filtering if let Some(paths) = path_filter { for path in paths { diff_opts.pathspec(*path); } } // Compute the diff parent -> commit let mut diff = repo.diff_tree_to_tree( Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts), )?; // Enable rename detection let mut find_opts = git2::DiffFindOptions::new(); diff.find_similar(Some(&mut find_opts))?; self.convert_diff_to_file_diffs(diff, &repo) } } } /// Convert git2::Diff to our Diff structs fn convert_diff_to_file_diffs( &self, diff: git2::Diff, repo: &Repository, ) -> Result, GitServiceError> { let mut file_diffs = Vec::new(); let mut delta_index: usize = 0; diff.foreach( &mut |delta, _| { if delta.status() == Delta::Unreadable { return true; } let status = delta.status(); // Decide if we should omit content due to size let mut content_omitted = false; // Check old blob size when applicable if !matches!(status, Delta::Added) { let oid = delta.old_file().id(); if !oid.is_zero() && let Ok(blob) = repo.find_blob(oid) && !blob.is_binary() && blob.size() > MAX_INLINE_DIFF_BYTES { content_omitted = true; } } // Check new blob size when applicable if !matches!(status, Delta::Deleted) { let oid = delta.new_file().id(); if !oid.is_zero() && let Ok(blob) = repo.find_blob(oid) && !blob.is_binary() && blob.size() > MAX_INLINE_DIFF_BYTES { content_omitted = true; } } // Only build old/new content if not omitted let (old_path, old_content) = if matches!(status, Delta::Added) { (None, None) } else { let path_opt = delta .old_file() .path() .map(|p| p.to_string_lossy().to_string()); if content_omitted { (path_opt, None) } else { let details = delta .old_file() .path() .map(|p| self.create_file_details(p, &delta.old_file().id(), repo)); ( details.as_ref().and_then(|f| f.file_name.clone()), details.and_then(|f| f.content), ) } }; let (new_path, new_content) = if matches!(status, Delta::Deleted) { (None, None) } else { let path_opt = delta .new_file() .path() .map(|p| p.to_string_lossy().to_string()); if content_omitted { (path_opt, None) } else { let details = delta .new_file() .path() .map(|p| self.create_file_details(p, &delta.new_file().id(), repo)); ( details.as_ref().and_then(|f| f.file_name.clone()), details.and_then(|f| f.content), ) } }; let mut change = match status { Delta::Added => DiffChangeKind::Added, Delta::Deleted => DiffChangeKind::Deleted, Delta::Modified => DiffChangeKind::Modified, Delta::Renamed => DiffChangeKind::Renamed, Delta::Copied => DiffChangeKind::Copied, Delta::Untracked => DiffChangeKind::Added, _ => DiffChangeKind::Modified, }; // Detect pure mode changes (e.g., chmod +/-x) and classify as PermissionChange if matches!(status, Delta::Modified) && delta.old_file().mode() != delta.new_file().mode() { // Only downgrade to PermissionChange if we KNOW content is unchanged if old_content.is_some() && new_content.is_some() && old_content == new_content { change = DiffChangeKind::PermissionChange; } } // Always compute line stats via libgit2 Patch let (additions, deletions) = if let Ok(Some(patch)) = git2::Patch::from_diff(&diff, delta_index) && let Ok((_ctx, adds, dels)) = patch.line_stats() { (Some(adds), Some(dels)) } else { (None, None) }; file_diffs.push(Diff { change, old_path, new_path, old_content, new_content, content_omitted, additions, deletions, repo_id: None, }); delta_index += 1; true }, None, None, None, )?; Ok(file_diffs) } /// Extract file path from a Diff (for indexing and ConversationPatch) pub fn diff_path(diff: &Diff) -> String { diff.new_path .clone() .or_else(|| diff.old_path.clone()) .unwrap_or_default() } /// Helper function to convert blob to string content fn blob_to_string(blob: &git2::Blob) -> Option { if blob.is_binary() { None // Skip binary files } else { std::str::from_utf8(blob.content()) .ok() .map(|s| s.to_string()) } } /// Helper function to read file content from filesystem with safety guards fn read_file_to_string(repo: &Repository, rel_path: &Path) -> Option { let workdir = repo.workdir()?; let abs_path = workdir.join(rel_path); // Read file from filesystem let bytes = match std::fs::read(&abs_path) { Ok(bytes) => bytes, Err(e) => { tracing::debug!("Failed to read file from filesystem: {:?}: {}", abs_path, e); return None; } }; // Size guard - skip files larger than UI inline threshold if bytes.len() > MAX_INLINE_DIFF_BYTES { tracing::debug!( "Skipping large file ({}KB): {:?}", bytes.len() / 1024, abs_path ); return None; } // Binary guard - skip files containing null bytes if bytes.contains(&0) { tracing::debug!("Skipping binary file: {:?}", abs_path); return None; } // UTF-8 validation match String::from_utf8(bytes) { Ok(content) => Some(content), Err(e) => { tracing::debug!("File is not valid UTF-8: {:?}: {}", abs_path, e); None } } } /// Create FileDiffDetails from path and blob with filesystem fallback fn create_file_details( &self, path: &Path, blob_id: &git2::Oid, repo: &Repository, ) -> FileDiffDetails { let file_name = path.to_string_lossy().to_string(); // Try to get content from blob first (for non-zero OIDs) let content = if !blob_id.is_zero() { repo.find_blob(*blob_id) .ok() .and_then(|blob| Self::blob_to_string(&blob)) .or_else(|| { // Fallback to filesystem for unstaged changes tracing::debug!( "Blob not found for non-zero OID, reading from filesystem: {}", file_name ); Self::read_file_to_string(repo, path) }) } else { // For zero OIDs, check filesystem directly (covers new/untracked files) Self::read_file_to_string(repo, path) }; FileDiffDetails { file_name: Some(file_name), content, } } /// Create Diff entries from git_cli::StatusDiffEntry /// New Diff format is flattened with change kind, paths, and optional contents. fn status_entry_to_diff(repo: &Repository, base_tree: &git2::Tree, e: StatusDiffEntry) -> Diff { // Map ChangeType to DiffChangeKind let mut change = match e.change { ChangeType::Added => DiffChangeKind::Added, ChangeType::Deleted => DiffChangeKind::Deleted, ChangeType::Modified => DiffChangeKind::Modified, ChangeType::Renamed => DiffChangeKind::Renamed, ChangeType::Copied => DiffChangeKind::Copied, // Treat type changes and unmerged as modified for now ChangeType::TypeChanged | ChangeType::Unmerged => DiffChangeKind::Modified, ChangeType::Unknown(_) => DiffChangeKind::Modified, }; // Determine old/new paths based on change let (old_path_opt, new_path_opt): (Option, Option) = match e.change { ChangeType::Added => (None, Some(e.path.clone())), ChangeType::Deleted => (Some(e.old_path.unwrap_or(e.path.clone())), None), ChangeType::Modified | ChangeType::TypeChanged | ChangeType::Unmerged => ( Some(e.old_path.unwrap_or(e.path.clone())), Some(e.path.clone()), ), ChangeType::Renamed | ChangeType::Copied => (e.old_path.clone(), Some(e.path.clone())), ChangeType::Unknown(_) => (e.old_path.clone(), Some(e.path.clone())), }; // Decide if we should omit content by size (either side) let mut content_omitted = false; // Old side (from base tree) if let Some(ref oldp) = old_path_opt { let rel = std::path::Path::new(oldp); if let Ok(entry) = base_tree.get_path(rel) && entry.kind() == Some(git2::ObjectType::Blob) && let Ok(blob) = repo.find_blob(entry.id()) && !blob.is_binary() && blob.size() > MAX_INLINE_DIFF_BYTES { content_omitted = true; } } // New side (from filesystem) if let Some(ref newp) = new_path_opt && let Some(workdir) = repo.workdir() { let abs = workdir.join(newp); if let Ok(md) = std::fs::metadata(&abs) && (md.len() as usize) > MAX_INLINE_DIFF_BYTES { content_omitted = true; } } // Load contents only if not omitted let (old_content, new_content) = if content_omitted { (None, None) } else { // Load old content from base tree if possible let old_content = if let Some(ref oldp) = old_path_opt { let rel = std::path::Path::new(oldp); match base_tree.get_path(rel) { Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => repo .find_blob(entry.id()) .ok() .and_then(|b| Self::blob_to_string(&b)), _ => None, } } else { None }; // Load new content from filesystem (worktree) when available let new_content = if let Some(ref newp) = new_path_opt { let rel = std::path::Path::new(newp); Self::read_file_to_string(repo, rel) } else { None }; (old_content, new_content) }; // If reported as Modified but content is identical, treat as a permission-only change if matches!(change, DiffChangeKind::Modified) && old_content.is_some() && new_content.is_some() && old_content == new_content { change = DiffChangeKind::PermissionChange; } // Compute line stats from available content let (additions, deletions) = match (&old_content, &new_content) { (Some(old), Some(new)) => { let (adds, dels) = compute_line_change_counts(old, new); (Some(adds), Some(dels)) } (Some(old), None) => { // File deleted - all lines are deletions (Some(0), Some(old.lines().count())) } (None, Some(new)) => { // File added - all lines are additions (Some(new.lines().count()), Some(0)) } (None, None) => (None, None), }; Diff { change, old_path: old_path_opt, new_path: new_path_opt, old_content, new_content, content_omitted, additions, deletions, repo_id: None, } } /// Find where a branch is currently checked out fn find_checkout_path_for_branch( &self, repo_path: &Path, branch_name: &str, ) -> Result, GitServiceError> { let git_cli = GitCli::new(); let worktrees = git_cli.list_worktrees(repo_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git worktree list failed: {e}")) })?; for worktree in worktrees { if let Some(ref branch) = worktree.branch && branch == branch_name { return Ok(Some(std::path::PathBuf::from(worktree.path))); } } Ok(None) } /// Merge changes from a task branch into the base branch. pub fn merge_changes( &self, base_worktree_path: &Path, task_worktree_path: &Path, task_branch_name: &str, base_branch_name: &str, commit_message: &str, ) -> Result { // Open the repositories let task_repo = self.open_repo(task_worktree_path)?; let base_repo = self.open_repo(base_worktree_path)?; // Check if base branch is ahead of task branch - this indicates the base has moved // ahead since the task was created, which should block the merge let (_, task_behind) = self.get_branch_status(base_worktree_path, task_branch_name, base_branch_name)?; if task_behind > 0 { return Err(GitServiceError::BranchesDiverged(format!( "Cannot merge: base branch '{base_branch_name}' is {task_behind} commits ahead of task branch '{task_branch_name}'. The base branch has moved forward since the task was created.", ))); } // Check where base branch is checked out (if anywhere) match self.find_checkout_path_for_branch(base_worktree_path, base_branch_name)? { Some(base_checkout_path) => { // base branch is checked out somewhere - use CLI merge let git_cli = GitCli::new(); // Safety check: base branch has no staged changes if git_cli .has_staged_changes(&base_checkout_path) .map_err(|e| { GitServiceError::InvalidRepository(format!("git diff --cached failed: {e}")) })? { return Err(GitServiceError::WorktreeDirty( base_branch_name.to_string(), "staged changes present".to_string(), )); } // Use CLI merge in base context self.ensure_cli_commit_identity(&base_checkout_path)?; let sha = git_cli .merge_squash_commit( &base_checkout_path, base_branch_name, task_branch_name, commit_message, ) .map_err(|e| { GitServiceError::InvalidRepository(format!("CLI merge failed: {e}")) })?; // Update task branch ref for continuity let task_refname = format!("refs/heads/{task_branch_name}"); git_cli .update_ref(base_worktree_path, &task_refname, &sha) .map_err(|e| { GitServiceError::InvalidRepository(format!("git update-ref failed: {e}")) })?; Ok(sha) } None => { // base branch not checked out anywhere - use libgit2 pure ref operations let task_branch = Self::find_branch(&task_repo, task_branch_name)?; let base_branch = Self::find_branch(&task_repo, base_branch_name)?; // Resolve commits let base_commit = base_branch.get().peel_to_commit()?; let task_commit = task_branch.get().peel_to_commit()?; // Create the squash commit in-memory (no checkout) and update the base branch ref let signature = self.signature_with_fallback(&task_repo)?; let squash_commit_id = self.perform_squash_merge( &task_repo, &base_commit, &task_commit, &signature, commit_message, base_branch_name, )?; // Update the task branch to the new squash commit so follow-up // work can continue from the merged state without conflicts. let task_refname = format!("refs/heads/{task_branch_name}"); base_repo.reference( &task_refname, squash_commit_id, true, "Reset task branch after squash merge", )?; Ok(squash_commit_id.to_string()) } } } fn get_branch_status_inner( &self, repo: &Repository, branch_ref: &Reference, base_branch_ref: &Reference, ) -> Result<(usize, usize), GitServiceError> { let (a, b) = repo.graph_ahead_behind( branch_ref.target().ok_or(GitServiceError::BranchNotFound( "Branch not found".to_string(), ))?, base_branch_ref .target() .ok_or(GitServiceError::BranchNotFound( "Branch not found".to_string(), ))?, )?; Ok((a, b)) } pub fn get_branch_status( &self, repo_path: &Path, branch_name: &str, base_branch_name: &str, ) -> Result<(usize, usize), GitServiceError> { let repo = Repository::open(repo_path)?; let branch = Self::find_branch(&repo, branch_name)?; let base_branch = Self::find_branch(&repo, base_branch_name)?; self.get_branch_status_inner( &repo, &branch.into_reference(), &base_branch.into_reference(), ) } pub fn get_base_commit( &self, repo_path: &Path, branch_name: &str, base_branch_name: &str, ) -> Result { let repo = Repository::open(repo_path)?; let branch = Self::find_branch(&repo, branch_name)?; let base_branch = Self::find_branch(&repo, base_branch_name)?; // Find the common ancestor (merge base) let oid = repo .merge_base( branch.get().peel_to_commit()?.id(), base_branch.get().peel_to_commit()?.id(), ) .map_err(GitServiceError::from)?; Ok(Commit::new(oid)) } pub fn get_remote_branch_status( &self, repo_path: &Path, branch_name: &str, base_branch_name: Option<&str>, ) -> Result<(usize, usize), GitServiceError> { let repo = Repository::open(repo_path)?; let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference(); // base branch is either given or upstream of branch_name let base_branch_ref = if let Some(bn) = base_branch_name { Self::find_branch(&repo, bn)? } else { repo.find_branch(branch_name, BranchType::Local)? .upstream()? } .into_reference(); let remote = self.get_remote_from_branch_ref(&repo, &base_branch_ref)?; self.fetch_all_from_remote(&repo, &remote)?; self.get_branch_status_inner(&repo, &branch_ref, &base_branch_ref) } pub fn is_worktree_clean(&self, worktree_path: &Path) -> Result { let repo = self.open_repo(worktree_path)?; match self.check_worktree_clean(&repo) { Ok(()) => Ok(true), Err(GitServiceError::WorktreeDirty(_, _)) => Ok(false), Err(e) => Err(e), } } /// Check if the worktree is clean (no uncommitted changes to tracked files) fn check_worktree_clean(&self, repo: &Repository) -> Result<(), GitServiceError> { let mut status_options = git2::StatusOptions::new(); status_options .include_untracked(false) // Don't include untracked files .include_ignored(false); // Don't include ignored files let statuses = repo.statuses(Some(&mut status_options))?; if !statuses.is_empty() { let mut dirty_files = Vec::new(); for entry in statuses.iter() { let status = entry.status(); // Only consider files that are actually tracked and modified if status.intersects( git2::Status::INDEX_MODIFIED | git2::Status::INDEX_NEW | git2::Status::INDEX_DELETED | git2::Status::INDEX_RENAMED | git2::Status::INDEX_TYPECHANGE | git2::Status::WT_MODIFIED | git2::Status::WT_DELETED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE, ) && let Some(path) = entry.path() { dirty_files.push(path.to_string()); } } if !dirty_files.is_empty() { let branch_name = repo .head() .ok() .and_then(|h| h.shorthand().map(|s| s.to_string())) .unwrap_or_else(|| "unknown branch".to_string()); return Err(GitServiceError::WorktreeDirty( branch_name, dirty_files.join(", "), )); } } Ok(()) } /// Get current HEAD information including branch name and commit OID pub fn get_head_info(&self, repo_path: &Path) -> Result { let repo = self.open_repo(repo_path)?; let head = repo.head()?; let branch = if let Some(branch_name) = head.shorthand() { branch_name.to_string() } else { "HEAD".to_string() }; let oid = if let Some(target_oid) = head.target() { target_oid.to_string() } else { // Handle case where HEAD exists but has no target (empty repo) return Err(GitServiceError::InvalidRepository( "Repository HEAD has no target commit".to_string(), )); }; Ok(HeadInfo { branch, oid }) } pub fn get_current_branch(&self, repo_path: &Path) -> Result { // Thin wrapper for backward compatibility match self.get_head_info(repo_path) { Ok(head_info) => Ok(head_info.branch), Err(GitServiceError::Git(git_err)) => Err(git_err), Err(_) => Err(git2::Error::from_str("Failed to get head info")), } } /// Get the commit OID (as hex string) for a given branch without modifying HEAD pub fn get_branch_oid( &self, repo_path: &Path, branch_name: &str, ) -> Result { let repo = self.open_repo(repo_path)?; let branch = Self::find_branch(&repo, branch_name)?; let oid = branch.get().peel_to_commit()?.id().to_string(); Ok(oid) } pub fn get_fork_point( &self, worktree_path: &Path, target_branch: &str, task_branch: &str, ) -> Result { let git = GitCli::new(); Ok(git.merge_base(worktree_path, target_branch, task_branch)?) } /// Get the subject/summary line for a given commit OID pub fn get_commit_subject( &self, repo_path: &Path, commit_sha: &str, ) -> Result { let repo = self.open_repo(repo_path)?; let oid = git2::Oid::from_str(commit_sha) .map_err(|_| GitServiceError::InvalidRepository("Invalid commit SHA".into()))?; let commit = repo.find_commit(oid)?; Ok(commit.summary().unwrap_or("(no subject)").to_string()) } /// Compare two OIDs and return (ahead, behind) counts: how many commits /// `from_oid` is ahead of and behind `to_oid`. pub fn ahead_behind_commits_by_oid( &self, repo_path: &Path, from_oid: &str, to_oid: &str, ) -> Result<(usize, usize), GitServiceError> { let repo = self.open_repo(repo_path)?; let from = git2::Oid::from_str(from_oid) .map_err(|_| GitServiceError::InvalidRepository("Invalid from OID".into()))?; let to = git2::Oid::from_str(to_oid) .map_err(|_| GitServiceError::InvalidRepository("Invalid to OID".into()))?; let (ahead, behind) = repo.graph_ahead_behind(from, to)?; Ok((ahead, behind)) } /// Return the full worktree status including all entries pub fn get_worktree_status( &self, worktree_path: &Path, ) -> Result { let cli = GitCli::new(); cli.get_worktree_status(worktree_path) .map_err(|e| GitServiceError::InvalidRepository(format!("git status failed: {e}"))) } /// Return (uncommitted_tracked_changes, untracked_files) counts in worktree pub fn get_worktree_change_counts( &self, worktree_path: &Path, ) -> Result<(usize, usize), GitServiceError> { let st = self.get_worktree_status(worktree_path)?; Ok((st.uncommitted_tracked, st.untracked)) } /// Evaluate whether any action is needed to reset to `target_commit_oid` and /// optionally perform the actions. pub fn reconcile_worktree_to_commit( &self, worktree_path: &Path, target_commit_oid: &str, options: WorktreeResetOptions, ) -> WorktreeResetOutcome { let WorktreeResetOptions { perform_reset, force_when_dirty, is_dirty, log_skip_when_dirty, } = options; let head_oid = self.get_head_info(worktree_path).ok().map(|h| h.oid); let mut outcome = WorktreeResetOutcome::default(); if head_oid.as_deref() != Some(target_commit_oid) || is_dirty { outcome.needed = true; if perform_reset { if is_dirty && !force_when_dirty { if log_skip_when_dirty { tracing::warn!("Worktree dirty; skipping reset as not forced"); } } else if let Err(e) = self.reset_worktree_to_commit( worktree_path, target_commit_oid, force_when_dirty, ) { tracing::error!("Failed to reset worktree: {}", e); } else { outcome.applied = true; } } } outcome } /// Reset the given worktree to the specified commit SHA. /// If `force` is false and the worktree is dirty, returns WorktreeDirty error. pub fn reset_worktree_to_commit( &self, worktree_path: &Path, commit_sha: &str, force: bool, ) -> Result<(), GitServiceError> { let repo = self.open_repo(worktree_path)?; if !force { // Avoid clobbering uncommitted changes unless explicitly forced self.check_worktree_clean(&repo)?; } let cli = GitCli::new(); cli.git(worktree_path, ["reset", "--hard", commit_sha]) .map_err(|e| { GitServiceError::InvalidRepository(format!("git reset --hard failed: {e}")) })?; if force { cli.git(worktree_path, ["clean", "-fd"]).map_err(|e| { GitServiceError::InvalidRepository(format!("git clean -fd failed: {e}")) })?; } // Reapply sparse-checkout if configured (non-fatal) let _ = cli.git(worktree_path, ["sparse-checkout", "reapply"]); Ok(()) } /// Add a worktree for a branch, optionally creating the branch pub fn add_worktree( &self, repo_path: &Path, worktree_path: &Path, branch: &str, create_branch: bool, ) -> Result<(), GitServiceError> { let git = GitCli::new(); git.worktree_add(repo_path, worktree_path, branch, create_branch) .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?; Ok(()) } /// Remove a worktree pub fn remove_worktree( &self, repo_path: &Path, worktree_path: &Path, force: bool, ) -> Result<(), GitServiceError> { let git = GitCli::new(); git.worktree_remove(repo_path, worktree_path, force) .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?; Ok(()) } /// Move a worktree to a new location pub fn move_worktree( &self, repo_path: &Path, old_path: &Path, new_path: &Path, ) -> Result<(), GitServiceError> { let git = GitCli::new(); git.worktree_move(repo_path, old_path, new_path) .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?; Ok(()) } pub fn prune_worktrees(&self, repo_path: &Path) -> Result<(), GitServiceError> { let git = GitCli::new(); git.worktree_prune(repo_path) .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?; Ok(()) } pub fn delete_branch( &self, repo_path: &Path, branch_name: &str, ) -> Result<(), GitServiceError> { let git = GitCli::new(); git.delete_branch(repo_path, branch_name) .map_err(|e| GitServiceError::InvalidRepository(e.to_string()))?; Ok(()) } pub fn get_all_branches(&self, repo_path: &Path) -> Result, git2::Error> { let repo = Repository::open(repo_path)?; let current_branch = self.get_current_branch(repo_path).unwrap_or_default(); let mut branches = Vec::new(); // Helper function to get last commit date for a branch let get_last_commit_date = |branch: &git2::Branch| -> Result, git2::Error> { if let Some(target) = branch.get().target() && let Ok(commit) = repo.find_commit(target) { let timestamp = commit.time().seconds(); return Ok(DateTime::from_timestamp(timestamp, 0).unwrap_or_else(Utc::now)); } Ok(Utc::now()) // Default to now if we can't get the commit date }; // Get local branches let local_branches = repo.branches(Some(BranchType::Local))?; for branch_result in local_branches { let (branch, _) = branch_result?; if let Some(name) = branch.name()? { let last_commit_date = get_last_commit_date(&branch)?; branches.push(GitBranch { name: name.to_string(), is_current: name == current_branch, is_remote: false, last_commit_date, }); } } // Get remote branches let remote_branches = repo.branches(Some(BranchType::Remote))?; for branch_result in remote_branches { let (branch, _) = branch_result?; if let Some(name) = branch.name()? { // Skip remote HEAD references if !name.ends_with("/HEAD") { let last_commit_date = get_last_commit_date(&branch)?; branches.push(GitBranch { name: name.to_string(), is_current: false, is_remote: true, last_commit_date, }); } } } // Sort branches: current first, then by most recent commit date branches.sort_by(|a, b| { if a.is_current && !b.is_current { std::cmp::Ordering::Less } else if !a.is_current && b.is_current { std::cmp::Ordering::Greater } else { // Sort by most recent commit date (newest first) b.last_commit_date.cmp(&a.last_commit_date) } }); Ok(branches) } /// Perform a squash merge of task branch into base branch, but fail on conflicts fn perform_squash_merge( &self, repo: &Repository, base_commit: &git2::Commit, task_commit: &git2::Commit, signature: &git2::Signature, commit_message: &str, base_branch_name: &str, ) -> Result { // In-memory merge to detect conflicts without touching the working tree let mut merge_opts = git2::MergeOptions::new(); // Safety and correctness options merge_opts.find_renames(true); // improve rename handling merge_opts.fail_on_conflict(true); // bail out instead of generating conflicted index let mut index = repo.merge_commits(base_commit, task_commit, Some(&merge_opts))?; // If there are conflicts, return an error if index.has_conflicts() { return Err(GitServiceError::MergeConflicts { message: "Merge failed due to conflicts. Please resolve conflicts manually." .to_string(), conflicted_files: vec![], }); } // Write the merged tree back to the repository let tree_id = index.write_tree_to(repo)?; let tree = repo.find_tree(tree_id)?; // Create a squash commit: use merged tree with base_commit as sole parent let squash_commit_id = repo.commit( None, // Don't update any reference yet signature, // Author signature, // Committer commit_message, // Custom message &tree, // Merged tree content &[base_commit], // Single parent: base branch commit )?; // Update the base branch reference to point to the new commit let refname = format!("refs/heads/{base_branch_name}"); repo.reference(&refname, squash_commit_id, true, "Squash merge")?; Ok(squash_commit_id) } /// Rebase a worktree branch onto a new base pub fn rebase_branch( &self, repo_path: &Path, worktree_path: &Path, new_base_branch: &str, old_base_branch: &str, task_branch: &str, ) -> Result { let worktree_repo = Repository::open(worktree_path)?; let main_repo = self.open_repo(repo_path)?; // Safety guard: never operate on a dirty worktree. This preserves any // uncommitted changes to tracked files by failing fast instead of // resetting or cherry-picking over them. Untracked files are allowed. self.check_worktree_clean(&worktree_repo)?; // If a rebase is already in progress, refuse to proceed instead of // aborting (which might destroy user changes mid-rebase). let git = GitCli::new(); if git.is_rebase_in_progress(worktree_path).unwrap_or(false) { return Err(GitServiceError::RebaseInProgress); } // Get the target base branch reference let nbr = Self::find_branch(&main_repo, new_base_branch)?.into_reference(); // If the target base is remote, update it first so CLI sees latest if nbr.is_remote() { self.fetch_branch_from_remote(&main_repo, &nbr)?; } // Ensure identity for any commits produced by rebase self.ensure_cli_commit_identity(worktree_path)?; // Use git CLI rebase to carry out the operation safely match git.rebase_onto(worktree_path, new_base_branch, old_base_branch, task_branch) { Ok(()) => {} Err(GitCliError::RebaseInProgress) => { return Err(GitServiceError::RebaseInProgress); } Err(GitCliError::CommandFailed(stderr)) => { // If the CLI indicates conflicts, return a concise, actionable error. let looks_like_conflict = stderr.contains("could not apply") || stderr.contains("CONFLICT") || stderr.to_lowercase().contains("resolve all conflicts"); if looks_like_conflict { // Determine current attempt branch name for clarity let attempt_branch = worktree_repo .head() .ok() .and_then(|h| h.shorthand().map(|s| s.to_string())) .unwrap_or_else(|| "(unknown)".to_string()); // List conflicted files (best-effort) let conflicted_files = git.get_conflicted_files(worktree_path).unwrap_or_default(); let files_part = if conflicted_files.is_empty() { "".to_string() } else { let mut sample = conflicted_files.clone(); let total = sample.len(); sample.truncate(10); let list = sample.join(", "); if total > sample.len() { format!( " Conflicted files (showing {} of {}): {}.", sample.len(), total, list ) } else { format!(" Conflicted files: {list}.") } }; let msg = format!( "Rebase encountered merge conflicts while rebasing '{attempt_branch}' onto '{new_base_branch}'.{files_part} Resolve conflicts and then continue or abort." ); return Err(GitServiceError::MergeConflicts { message: msg, conflicted_files, }); } return Err(GitServiceError::InvalidRepository(format!( "Rebase failed: {}", stderr.lines().next().unwrap_or("") ))); } Err(e) => { return Err(GitServiceError::InvalidRepository(format!( "git rebase failed: {e}" ))); } } // Return resulting HEAD commit let final_commit = worktree_repo.head()?.peel_to_commit()?; Ok(final_commit.id().to_string()) } pub fn find_branch_type( &self, repo_path: &Path, branch_name: &str, ) -> Result { let repo = self.open_repo(repo_path)?; // Try to find the branch as a local branch first match repo.find_branch(branch_name, BranchType::Local) { Ok(_) => Ok(BranchType::Local), Err(_) => { // If not found, try to find it as a remote branch match repo.find_branch(branch_name, BranchType::Remote) { Ok(_) => Ok(BranchType::Remote), Err(_) => Err(GitServiceError::BranchNotFound(branch_name.to_string())), } } } } pub fn check_branch_exists( &self, repo_path: &Path, branch_name: &str, ) -> Result { let repo = self.open_repo(repo_path)?; match repo.find_branch(branch_name, BranchType::Local) { Ok(_) => Ok(true), Err(_) => match repo.find_branch(branch_name, BranchType::Remote) { Ok(_) => Ok(true), Err(_) => Ok(false), }, } } pub fn rename_local_branch( &self, worktree_path: &Path, old_branch_name: &str, new_branch_name: &str, ) -> Result<(), GitServiceError> { let repo = self.open_repo(worktree_path)?; let mut branch = repo .find_branch(old_branch_name, BranchType::Local) .map_err(|_| GitServiceError::BranchNotFound(old_branch_name.to_string()))?; branch.rename(new_branch_name, false)?; repo.set_head(&format!("refs/heads/{new_branch_name}"))?; Ok(()) } /// Return true if a rebase is currently in progress in this worktree. pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result { let git = GitCli::new(); git.is_rebase_in_progress(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git rebase state check failed: {e}")) }) } pub fn detect_conflict_op( &self, worktree_path: &Path, ) -> Result, GitServiceError> { let git = GitCli::new(); if git.is_rebase_in_progress(worktree_path).unwrap_or(false) { return Ok(Some(ConflictOp::Rebase)); } if git.is_merge_in_progress(worktree_path).unwrap_or(false) { return Ok(Some(ConflictOp::Merge)); } if git .is_cherry_pick_in_progress(worktree_path) .unwrap_or(false) { return Ok(Some(ConflictOp::CherryPick)); } if git.is_revert_in_progress(worktree_path).unwrap_or(false) { return Ok(Some(ConflictOp::Revert)); } Ok(None) } /// List conflicted (unmerged) files in the worktree. pub fn get_conflicted_files( &self, worktree_path: &Path, ) -> Result, GitServiceError> { let git = GitCli::new(); git.get_conflicted_files(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git diff for conflicts failed: {e}")) }) } /// Abort an in-progress rebase in this worktree (no-op if none). pub fn abort_rebase(&self, worktree_path: &Path) -> Result<(), GitServiceError> { let git = GitCli::new(); git.abort_rebase(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git rebase --abort failed: {e}")) }) } /// Continue an in-progress rebase. Fails if there are unresolved conflicts. pub fn continue_rebase(&self, worktree_path: &Path) -> Result<(), GitServiceError> { let git = GitCli::new(); git.continue_rebase(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git rebase --continue failed: {e}")) }) } pub fn abort_conflicts(&self, worktree_path: &Path) -> Result<(), GitServiceError> { let git = GitCli::new(); if git.is_rebase_in_progress(worktree_path).unwrap_or(false) { // If there are no conflicted files, prefer `git rebase --quit` to clean up metadata let has_conflicts = !self .get_conflicted_files(worktree_path) .unwrap_or_default() .is_empty(); if has_conflicts { return self.abort_rebase(worktree_path); } else { return git.quit_rebase(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git rebase --quit failed: {e}")) }); } } if git.is_merge_in_progress(worktree_path).unwrap_or(false) { return git.abort_merge(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git merge --abort failed: {e}")) }); } if git .is_cherry_pick_in_progress(worktree_path) .unwrap_or(false) { return git.abort_cherry_pick(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git cherry-pick --abort failed: {e}")) }); } if git.is_revert_in_progress(worktree_path).unwrap_or(false) { return git.abort_revert(worktree_path).map_err(|e| { GitServiceError::InvalidRepository(format!("git revert --abort failed: {e}")) }); } Ok(()) } pub fn find_branch<'a>( repo: &'a Repository, branch_name: &str, ) -> Result, GitServiceError> { // Try to find the branch as a local branch first match repo.find_branch(branch_name, BranchType::Local) { Ok(branch) => Ok(branch), Err(_) => { // If not found, try to find it as a remote branch match repo.find_branch(branch_name, BranchType::Remote) { Ok(branch) => Ok(branch), Err(_) => Err(GitServiceError::BranchNotFound(branch_name.to_string())), } } } } pub fn get_remote_from_branch_name( &self, repo_path: &Path, branch_name: &str, ) -> Result { let repo = Repository::open(repo_path)?; let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference(); let remote = self.get_remote_from_branch_ref(&repo, &branch_ref)?; let name = remote.name().map(|name| name.to_string()).ok_or_else(|| { GitServiceError::InvalidRepository(format!( "Remote for branch '{branch_name}' has no name" )) })?; let url = remote.url().map(|url| url.to_string()).ok_or_else(|| { GitServiceError::InvalidRepository(format!( "Remote for branch '{branch_name}' has no URL" )) })?; Ok(GitRemote { name, url }) } pub fn get_remote_url( &self, repo_path: &Path, remote_name: &str, ) -> Result { let cli = GitCli::new(); cli.get_remote_url(repo_path, remote_name) .map_err(GitServiceError::from) } pub fn get_default_remote(&self, repo_path: &Path) -> Result { let repo = self.open_repo(repo_path)?; self.default_remote(&repo, repo_path) } pub fn list_remotes(&self, repo_path: &Path) -> Result, GitServiceError> { let cli = GitCli::new(); let remotes = cli.list_remotes(repo_path)?; Ok(remotes .into_iter() .map(|(name, url)| GitRemote { name, url }) .collect()) } pub fn check_remote_branch_exists( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result { let git_cli = GitCli::new(); git_cli .check_remote_branch_exists(repo_path, remote_url, branch_name) .map_err(GitServiceError::from) } pub fn fetch_branch( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result<(), GitServiceError> { let git_cli = GitCli::new(); let refspec = format!("+refs/heads/{branch_name}:refs/heads/{branch_name}"); git_cli .fetch_with_refspec(repo_path, remote_url, &refspec) .map_err(GitServiceError::from) } pub fn resolve_remote_for_branch( &self, repo_path: &Path, branch_name: &str, ) -> Result { self.get_remote_from_branch_name(repo_path, branch_name) .or_else(|_| self.get_default_remote(repo_path)) } fn get_remote_from_branch_ref<'a>( &self, repo: &'a Repository, branch_ref: &Reference, ) -> Result, GitServiceError> { let branch_name = branch_ref .name() .map(|name| name.to_string()) .ok_or_else(|| GitServiceError::InvalidRepository("Invalid branch ref".into()))?; let remote_name_buf = repo.branch_remote_name(&branch_name)?; let remote_name = str::from_utf8(&remote_name_buf) .map_err(|e| { GitServiceError::InvalidRepository(format!( "Invalid remote name for branch {branch_name}: {e}" )) })? .to_string(); repo.find_remote(&remote_name).map_err(|_| { GitServiceError::InvalidRepository(format!( "Remote '{remote_name}' for branch '{branch_name}' not found" )) }) } pub fn push_to_remote( &self, worktree_path: &Path, branch_name: &str, force: bool, ) -> Result<(), GitServiceError> { let repo = Repository::open(worktree_path)?; self.check_worktree_clean(&repo)?; // Get the remote let remote = self.default_remote(&repo, worktree_path)?; let git_cli = GitCli::new(); if let Err(e) = git_cli.push(worktree_path, &remote.url, branch_name, force) { tracing::error!("Push to remote failed: {}", e); return Err(e.into()); } let mut branch = Self::find_branch(&repo, branch_name)?; if !branch.get().is_remote() { if let Some(branch_target) = branch.get().target() { let remote_ref = format!("refs/remotes/{}/{branch_name}", remote.name); repo.reference( &remote_ref, branch_target, true, "update remote tracking branch", )?; } branch.set_upstream(Some(&format!("{}/{branch_name}", remote.name)))?; } Ok(()) } /// Fetch from remote repository using native git authentication fn fetch_from_remote( &self, repo: &Repository, remote: &Remote, refspec: &str, ) -> Result<(), GitServiceError> { // Get the remote let remote_url = remote .url() .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; let git_cli = GitCli::new(); if let Err(e) = git_cli.fetch_with_refspec(repo.path(), remote_url, refspec) { tracing::error!("Fetch from GitHub failed: {}", e); return Err(e.into()); } Ok(()) } /// Fetch from remote repository using native git authentication fn fetch_branch_from_remote( &self, repo: &Repository, branch: &Reference, ) -> Result<(), GitServiceError> { let remote = self.get_remote_from_branch_ref(repo, branch)?; let default_remote = self.default_remote(repo, repo.path())?; let remote_name = remote.name().unwrap_or(&default_remote.name); let dest_ref = branch .name() .ok_or_else(|| GitServiceError::InvalidRepository("Invalid branch ref".into()))?; let remote_prefix = format!("refs/remotes/{remote_name}/"); let src_ref = dest_ref.replacen(&remote_prefix, "refs/heads/", 1); let refspec = format!("+{src_ref}:{dest_ref}"); self.fetch_from_remote(repo, &remote, &refspec) } /// Fetch from remote repository using native git authentication fn fetch_all_from_remote( &self, repo: &Repository, remote: &Remote, ) -> Result<(), GitServiceError> { let default_remote = self.default_remote(repo, repo.path())?; let remote_name = remote.name().unwrap_or(&default_remote.name); let refspec = format!("+refs/heads/*:refs/remotes/{remote_name}/*"); self.fetch_from_remote(repo, remote, &refspec) } /// Clone a repository to the specified directory #[cfg(feature = "cloud")] pub fn clone_repository( clone_url: &str, target_path: &Path, token: Option<&str>, ) -> Result { use git2::{Cred, FetchOptions, RemoteCallbacks}; if let Some(parent) = target_path.parent() { std::fs::create_dir_all(parent)?; } // Set up callbacks for authentication if token is provided let mut callbacks = RemoteCallbacks::new(); if let Some(token) = token { callbacks.credentials(|_url, username_from_url, _allowed_types| { Cred::userpass_plaintext(username_from_url.unwrap_or("git"), token) }); } else { // Fallback to SSH agent and key file authentication callbacks.credentials(|_url, username_from_url, _| { // Try SSH agent first if let Some(username) = username_from_url && let Ok(cred) = Cred::ssh_key_from_agent(username) { return Ok(cred); } // Fallback to key file (~/.ssh/id_rsa) let home = dirs::home_dir() .ok_or_else(|| git2::Error::from_str("Could not find home directory"))?; let key_path = home.join(".ssh").join("id_rsa"); Cred::ssh_key(username_from_url.unwrap_or("git"), None, &key_path, None) }); } // Set up fetch options with our callbacks let mut fetch_opts = FetchOptions::new(); fetch_opts.remote_callbacks(callbacks); // Create a repository builder with fetch options let mut builder = git2::build::RepoBuilder::new(); builder.fetch_options(fetch_opts); let repo = builder.clone(clone_url, target_path)?; tracing::info!( "Successfully cloned repository from {} to {}", clone_url, target_path.display() ); Ok(repo) } /// Collect file statistics from recent commits for ranking purposes pub fn collect_recent_file_stats( &self, repo_path: &Path, commit_limit: usize, ) -> Result, GitServiceError> { let repo = self.open_repo(repo_path)?; let mut stats: HashMap = HashMap::new(); // Set up revision walk from HEAD let mut revwalk = repo.revwalk()?; revwalk.push_head()?; revwalk.set_sorting(Sort::TIME)?; // Iterate through recent commits for (commit_index, oid_result) in revwalk.take(commit_limit).enumerate() { let oid = oid_result?; let commit = repo.find_commit(oid)?; // Get commit timestamp let commit_time = { let time = commit.time(); DateTime::from_timestamp(time.seconds(), 0).unwrap_or_else(Utc::now) }; // Get the commit tree let commit_tree = commit.tree()?; // For the first commit (no parent), diff against empty tree let parent_tree = if commit.parent_count() == 0 { None } else { Some(commit.parent(0)?.tree()?) }; // Create diff between parent and current commit let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?; // Process each changed file in this commit diff.foreach( &mut |delta, _progress| { // Get the file path - prefer new file path, fall back to old if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path()) { let path_str = path.to_string_lossy().to_string(); // Update or insert file stats let stat = stats.entry(path_str).or_insert(FileStat { last_index: commit_index, commit_count: 0, last_time: commit_time, }); // Increment commit count stat.commit_count += 1; // Keep the most recent change (smallest index) if commit_index < stat.last_index { stat.last_index = commit_index; stat.last_time = commit_time; } } true // Continue iteration }, None, // No binary callback None, // No hunk callback None, // No line callback )?; } Ok(stats) } } ================================================ FILE: crates/git/src/validation.rs ================================================ pub fn is_valid_branch_prefix(prefix: &str) -> bool { if prefix.is_empty() { return true; } if prefix.contains('/') { return false; } git2::Branch::name_is_valid(&format!("{prefix}/x")).unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_prefixes() { assert!(is_valid_branch_prefix("")); assert!(is_valid_branch_prefix("vk")); assert!(is_valid_branch_prefix("feature")); assert!(is_valid_branch_prefix("hotfix-123")); assert!(is_valid_branch_prefix("foo.bar")); assert!(is_valid_branch_prefix("foo_bar")); assert!(is_valid_branch_prefix("FOO-Bar")); } #[test] fn test_invalid_prefixes() { assert!(!is_valid_branch_prefix("foo/bar")); assert!(!is_valid_branch_prefix("foo..bar")); assert!(!is_valid_branch_prefix("foo@{")); assert!(!is_valid_branch_prefix("foo.lock")); // Note: git2 allows trailing dots in some contexts, but we enforce stricter rules // for prefixes by checking the full branch name format assert!(!is_valid_branch_prefix("foo bar")); assert!(!is_valid_branch_prefix("foo?")); assert!(!is_valid_branch_prefix("foo*")); assert!(!is_valid_branch_prefix("foo~")); assert!(!is_valid_branch_prefix("foo^")); assert!(!is_valid_branch_prefix("foo:")); assert!(!is_valid_branch_prefix("foo[")); assert!(!is_valid_branch_prefix("/foo")); assert!(!is_valid_branch_prefix("foo/")); assert!(!is_valid_branch_prefix(".foo")); } } ================================================ FILE: crates/git/tests/git_ops_safety.rs ================================================ use std::{ fs, io::Write, path::{Path, PathBuf}, }; use git::{GitCli, GitCliError, GitService}; use git2::{PushOptions, Repository, build::CheckoutBuilder}; use tempfile::TempDir; // Avoid direct git CLI usage in tests; exercise GitService instead. fn write_file>(base: P, rel: &str, content: &str) { let path = base.as_ref().join(rel); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } let mut f = fs::File::create(&path).unwrap(); f.write_all(content.as_bytes()).unwrap(); } fn commit_all(repo: &Repository, message: &str) { let mut index = repo.index().unwrap(); index .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) .unwrap(); index.write().unwrap(); let tree_id = index.write_tree().unwrap(); let tree = repo.find_tree(tree_id).unwrap(); let sig = repo.signature().unwrap(); let parents: Vec = match repo.head() { Ok(h) => vec![h.peel_to_commit().unwrap()], Err(e) if e.code() == git2::ErrorCode::UnbornBranch => vec![], Err(e) => panic!("failed to read HEAD: {e}"), }; let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); let update_ref = if repo.head().is_ok() { Some("HEAD") } else { None }; repo.commit(update_ref, &sig, &sig, message, &tree, &parent_refs) .unwrap(); } fn checkout_branch(repo: &Repository, name: &str) { repo.set_head(&format!("refs/heads/{name}")).unwrap(); let mut co = CheckoutBuilder::new(); co.force(); repo.checkout_head(Some(&mut co)).unwrap(); } fn create_branch_from_head(repo: &Repository, name: &str) { let head = repo.head().unwrap().peel_to_commit().unwrap(); let _ = repo.branch(name, &head, true).unwrap(); } fn configure_user(repo: &Repository) { let mut cfg = repo.config().unwrap(); cfg.set_str("user.name", "Test User").unwrap(); cfg.set_str("user.email", "test@example.com").unwrap(); } fn push_ref(repo: &Repository, local: &str, remote: &str) { let mut remote_handle = repo.find_remote("origin").unwrap(); let mut opts = PushOptions::new(); let spec = format!("+{local}:{remote}"); remote_handle .push(&[spec.as_str()], Some(&mut opts)) .unwrap(); } fn add_path(repo_path: &Path, path: &str) { let git = GitCli::new(); git.git(repo_path, ["add", path]).unwrap(); } use git::DiffTarget; // Non-conflicting setup used by several tests fn setup_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) { let repo_path = root.path().join("repo"); let worktree_path = root.path().join("wt-feature"); let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); write_file(&repo_path, "common.txt", "base\n"); commit_all(&repo, "initial main commit"); create_branch_from_head(&repo, "old-base"); checkout_branch(&repo, "old-base"); write_file(&repo_path, "base.txt", "from old-base\n"); commit_all(&repo, "old-base commit"); checkout_branch(&repo, "main"); create_branch_from_head(&repo, "new-base"); checkout_branch(&repo, "new-base"); write_file(&repo_path, "base.txt", "from new-base\n"); commit_all(&repo, "new-base commit"); checkout_branch(&repo, "old-base"); create_branch_from_head(&repo, "feature"); let svc = GitService::new(); svc.add_worktree(&repo_path, &worktree_path, "feature", false) .expect("create worktree"); write_file(&worktree_path, "feat.txt", "feat change\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature commit"); (repo_path, worktree_path) } // Conflicting setup to simulate interactive rebase interruption fn setup_conflict_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) { let repo_path = root.path().join("repo"); let worktree_path = root.path().join("wt-feature"); let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); write_file(&repo_path, "conflict.txt", "base\n"); commit_all(&repo, "initial main commit"); // old-base modifies conflict.txt one way create_branch_from_head(&repo, "old-base"); checkout_branch(&repo, "old-base"); write_file(&repo_path, "conflict.txt", "old-base version\n"); commit_all(&repo, "old-base change"); // feature builds on old-base and modifies same lines differently create_branch_from_head(&repo, "feature"); // new-base modifies in a conflicting way checkout_branch(&repo, "main"); create_branch_from_head(&repo, "new-base"); checkout_branch(&repo, "new-base"); write_file(&repo_path, "conflict.txt", "new-base version\n"); commit_all(&repo, "new-base change"); // add a worktree for feature and create the conflicting commit let svc = GitService::new(); svc.add_worktree(&repo_path, &worktree_path, "feature", false) .expect("create worktree"); let wt_repo = Repository::open(&worktree_path).unwrap(); write_file(&worktree_path, "conflict.txt", "feature version\n"); commit_all(&wt_repo, "feature conflicting change"); (repo_path, worktree_path) } // Setup where feature has no unique commits (feature == old-base) fn setup_no_unique_feature_repo(root: &TempDir) -> (PathBuf, PathBuf) { let repo_path = root.path().join("repo"); let worktree_path = root.path().join("wt-feature"); let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); write_file(&repo_path, "base.txt", "main base\n"); commit_all(&repo, "initial main commit"); // Create old-base at this point create_branch_from_head(&repo, "old-base"); // Create new-base diverging checkout_branch(&repo, "main"); create_branch_from_head(&repo, "new-base"); checkout_branch(&repo, "new-base"); write_file(&repo_path, "advance.txt", "new base\n"); commit_all(&repo, "advance new-base"); // Create feature equal to old-base (no unique commits) checkout_branch(&repo, "old-base"); create_branch_from_head(&repo, "feature"); let svc = GitService::new(); svc.add_worktree(&repo_path, &worktree_path, "feature", false) .expect("create worktree"); (repo_path, worktree_path) } // Simple two-way conflict between main and feature on the same file fn setup_direct_conflict_repo(root: &TempDir) -> (PathBuf, PathBuf) { let repo_path = root.path().join("repo"); let worktree_path = root.path().join("wt-feature"); let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); write_file(&repo_path, "conflict.txt", "base\n"); commit_all(&repo, "initial main commit"); // Create feature and commit conflicting change create_branch_from_head(&repo, "feature"); let svc = GitService::new(); svc.add_worktree(&repo_path, &worktree_path, "feature", false) .expect("create worktree"); let wt_repo = Repository::open(&worktree_path).unwrap(); write_file(&worktree_path, "conflict.txt", "feature change\n"); commit_all(&wt_repo, "feature change"); // Change main in a conflicting way checkout_branch(&repo, "main"); write_file(&repo_path, "conflict.txt", "main change\n"); commit_all(&repo, "main change"); (repo_path, worktree_path) } #[test] fn push_reports_non_fast_forward() { let temp_dir = TempDir::new().unwrap(); let remote_path = temp_dir.path().join("remote.git"); Repository::init_bare(&remote_path).expect("init bare remote"); let remote_url = remote_path.to_str().expect("remote path str"); // Seed the bare repo with an initial main branch commit let seed_path = temp_dir.path().join("seed"); let service = GitService::new(); service .initialize_repo_with_main_branch(&seed_path) .expect("init seed repo"); let seed_repo = Repository::open(&seed_path).expect("open seed repo"); configure_user(&seed_repo); seed_repo.remote("origin", remote_url).expect("add remote"); push_ref(&seed_repo, "refs/heads/main", "refs/heads/main"); Repository::open_bare(&remote_path) .expect("open bare remote") .set_head("refs/heads/main") .expect("set remote HEAD"); // Local clone that will attempt the push later let local_path = temp_dir.path().join("local"); let local_repo = Repository::clone(remote_url, &local_path).expect("clone local"); configure_user(&local_repo); checkout_branch(&local_repo, "main"); write_file(&local_path, "file.txt", "initial local\n"); commit_all(&local_repo, "initial local commit"); push_ref(&local_repo, "refs/heads/main", "refs/heads/main"); // Separate clone simulates someone else pushing first let updater_path = temp_dir.path().join("updater"); let updater_repo = Repository::clone(remote_url, &updater_path).expect("clone updater"); configure_user(&updater_repo); checkout_branch(&updater_repo, "main"); write_file(&updater_path, "file.txt", "upstream change\n"); commit_all(&updater_repo, "upstream commit"); push_ref(&updater_repo, "refs/heads/main", "refs/heads/main"); // Local branch diverges but has not fetched the updater's commit write_file(&local_path, "file.txt", "local change\n"); commit_all(&local_repo, "local commit"); let remote = local_repo.find_remote("origin").expect("origin remote"); let remote_url_string = remote.url().expect("origin url").to_string(); let git_cli = GitCli::new(); let result = git_cli.push(&local_path, &remote_url_string, "main", false); match result { Err(GitCliError::PushRejected(msg)) => { let lower = msg.to_ascii_lowercase(); assert!( lower.contains("failed to push some refs") || lower.contains("fetch first"), "unexpected stderr: {msg}" ); } Err(other) => panic!("expected push rejected, got {other:?}"), Ok(_) => panic!("push unexpectedly succeeded"), } } #[test] fn fetch_with_missing_ref_returns_error() { let temp_dir = TempDir::new().unwrap(); let remote_path = temp_dir.path().join("remote.git"); Repository::init_bare(&remote_path).expect("init bare remote"); let remote_url = remote_path.to_str().expect("remote path str"); let seed_path = temp_dir.path().join("seed"); let service = GitService::new(); service .initialize_repo_with_main_branch(&seed_path) .expect("init seed repo"); let seed_repo = Repository::open(&seed_path).expect("open seed repo"); configure_user(&seed_repo); seed_repo.remote("origin", remote_url).expect("add remote"); push_ref(&seed_repo, "refs/heads/main", "refs/heads/main"); Repository::open_bare(&remote_path) .expect("open bare remote") .set_head("refs/heads/main") .expect("set remote HEAD"); let local_path = temp_dir.path().join("local"); Repository::clone(remote_url, &local_path).expect("clone local"); let git_cli = GitCli::new(); let refspec = "+refs/heads/missing:refs/remotes/origin/missing"; let result = git_cli.fetch_with_refspec(&local_path, remote_url, refspec); match result { Err(GitCliError::CommandFailed(msg)) => { assert!( msg.to_ascii_lowercase() .contains("couldn't find remote ref"), "unexpected stderr: {msg}" ); } Err(other) => panic!("expected command failed, got {other:?}"), Ok(_) => panic!("fetch unexpectedly succeeded"), } } #[test] fn push_and_fetch_roundtrip_updates_tracking_branch() { let temp_dir = TempDir::new().unwrap(); let remote_path = temp_dir.path().join("remote.git"); Repository::init_bare(&remote_path).expect("init bare remote"); let remote_url = remote_path.to_str().expect("remote path str"); let seed_path = temp_dir.path().join("seed"); let service = GitService::new(); service .initialize_repo_with_main_branch(&seed_path) .expect("init seed repo"); let seed_repo = Repository::open(&seed_path).expect("open seed repo"); configure_user(&seed_repo); seed_repo.remote("origin", remote_url).expect("add remote"); push_ref(&seed_repo, "refs/heads/main", "refs/heads/main"); Repository::open_bare(&remote_path) .expect("open bare remote") .set_head("refs/heads/main") .expect("set remote HEAD"); let producer_path = temp_dir.path().join("producer"); let producer_repo = Repository::clone(remote_url, &producer_path).expect("clone producer"); configure_user(&producer_repo); checkout_branch(&producer_repo, "main"); let consumer_path = temp_dir.path().join("consumer"); let consumer_repo = Repository::clone(remote_url, &consumer_path).expect("clone consumer"); configure_user(&consumer_repo); checkout_branch(&consumer_repo, "main"); let old_oid = consumer_repo .find_reference("refs/remotes/origin/main") .expect("consumer tracking ref") .target() .expect("consumer tracking ref"); write_file(&producer_path, "file.txt", "new work\n"); commit_all(&producer_repo, "producer commit"); let remote = producer_repo.find_remote("origin").expect("origin remote"); let remote_url_string = remote.url().expect("origin url").to_string(); let git_cli = GitCli::new(); git_cli .push(&producer_path, &remote_url_string, "main", false) .expect("push succeeded"); let new_oid = producer_repo .head() .expect("producer head") .target() .expect("producer head oid"); assert_ne!(old_oid, new_oid, "producer created new commit"); git_cli .fetch_with_refspec( &consumer_path, &remote_url_string, "+refs/heads/main:refs/remotes/origin/main", ) .expect("fetch succeeded"); let updated_oid = consumer_repo .find_reference("refs/remotes/origin/main") .expect("updated tracking ref") .target() .expect("updated tracking ref"); assert_eq!( updated_oid, new_oid, "tracking branch advanced to remote head" ); } #[test] fn rebase_preserves_untracked_files() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); write_file(&worktree_path, "scratch/untracked.txt", "temporary note\n"); let service = GitService::new(); let res = service.rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ); assert!(res.is_ok(), "rebase should succeed: {res:?}"); let scratch = worktree_path.join("scratch/untracked.txt"); let content = fs::read_to_string(&scratch).expect("untracked file exists"); assert_eq!(content, "temporary note\n"); } #[test] fn rebase_aborts_on_uncommitted_tracked_changes() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); write_file(&worktree_path, "feat.txt", "feat change (edited)\n"); let service = GitService::new(); let res = service.rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ); assert!(res.is_err(), "rebase should fail on dirty worktree"); let edited = fs::read_to_string(worktree_path.join("feat.txt")).unwrap(); assert_eq!(edited, "feat change (edited)\n"); } #[test] fn rebase_aborts_if_untracked_would_be_overwritten_by_base() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); write_file(&worktree_path, "base.txt", "my scratch note\n"); let service = GitService::new(); let res = service.rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ); assert!( res.is_err(), "rebase should fail due to untracked overwrite risk" ); let content = std::fs::read_to_string(worktree_path.join("base.txt")).unwrap(); assert_eq!(content, "my scratch note\n"); } #[test] fn merge_does_not_overwrite_main_repo_untracked_files() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); write_file(&worktree_path, "danger.txt", "tracked from feature\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "add danger.txt in feature"); write_file(&repo_path, "danger.txt", "my untracked data\n"); let main_repo = Repository::open(&repo_path).unwrap(); checkout_branch(&main_repo, "main"); let service = GitService::new(); let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "squash merge", ); assert!( res.is_err(), "merge should refuse due to untracked conflict" ); // Untracked file remains untouched let content = std::fs::read_to_string(repo_path.join("danger.txt")).unwrap(); assert_eq!(content, "my untracked data\n"); } #[test] fn merge_does_not_touch_tracked_uncommitted_changes_in_base_worktree() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); // Prepare: modify a tracked file in the base worktree (main) without committing let _main_repo = Repository::open(&repo_path).unwrap(); // Base branch commits will be advanced by the merge operation; record before via service let g = GitService::new(); let before_oid = g.get_branch_oid(&repo_path, "main").unwrap(); // Create a tracked file that will also be added by feature branch to simulate overlap write_file(&repo_path, "danger2.txt", "my staged change\n"); { // stage and then unstage to leave WT_MODIFIED? Simpler: just modify an existing tracked file // Use common.txt which is tracked write_file(&repo_path, "common.txt", "edited locally\n"); } // Feature adds a change and is committed in worktree write_file(&worktree_path, "danger2.txt", "feature tracked\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature adds danger2.txt"); // Merge via service (squash into main) should not modify files in the main worktree let service = GitService::new(); let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "squash merge", ); assert!( res.is_ok(), "merge should succeed without touching worktree" ); // Confirm the local edit to tracked file remains let content = std::fs::read_to_string(repo_path.join("common.txt")).unwrap(); assert_eq!(content, "edited locally\n"); // Confirm the main branch ref advanced let after_oid = g.get_branch_oid(&repo_path, "main").unwrap(); assert_ne!(before_oid, after_oid, "main ref should be updated by merge"); } #[test] fn merge_refuses_with_staged_changes_on_base() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let s = GitService::new(); // ensure main is checked out let repo = Repository::open(&repo_path).unwrap(); checkout_branch(&repo, "main"); // feature adds change and commits write_file(&worktree_path, "m.txt", "feature\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feat change"); // main has staged change write_file(&repo_path, "staged.txt", "staged\n"); add_path(&repo_path, "staged.txt"); let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash"); assert!(res.is_err(), "should refuse merge due to staged changes"); // staged file remains let content = std::fs::read_to_string(repo_path.join("staged.txt")).unwrap(); assert_eq!(content, "staged\n"); } #[test] fn merge_preserves_unstaged_changes_on_base() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let s = GitService::new(); let repo = Repository::open(&repo_path).unwrap(); checkout_branch(&repo, "main"); // modify unstaged write_file(&repo_path, "common.txt", "local edited\n"); // feature modifies a different file write_file(&worktree_path, "merged.txt", "merged content\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature merged"); let _sha = s .merge_changes(&repo_path, &worktree_path, "feature", "main", "squash") .unwrap(); // local edit preserved let loc = std::fs::read_to_string(repo_path.join("common.txt")).unwrap(); assert_eq!(loc, "local edited\n"); // merged file updated let m = std::fs::read_to_string(repo_path.join("merged.txt")).unwrap(); assert_eq!(m, "merged content\n"); } #[test] fn update_ref_does_not_destroy_feature_worktree_dirty_state() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let s = GitService::new(); let repo = Repository::open(&repo_path).unwrap(); // ensure main is checked out checkout_branch(&repo, "main"); // feature makes an initial change and commits write_file(&worktree_path, "f.txt", "feat\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feat commit"); // dirty change in feature worktree (uncommitted) write_file(&worktree_path, "dirty.txt", "unstaged\n"); // merge from feature into main (CLI path updates task ref via update-ref) let sha = s .merge_changes(&repo_path, &worktree_path, "feature", "main", "squash") .unwrap(); // uncommitted change in feature worktree preserved let dirty = std::fs::read_to_string(worktree_path.join("dirty.txt")).unwrap(); assert_eq!(dirty, "unstaged\n"); // feature branch ref updated to the squash commit in main repo let feature_oid = s.get_branch_oid(&repo_path, "feature").unwrap(); assert_eq!(feature_oid, sha); // and the feature worktree HEAD now points to that commit let head = s.get_head_info(&worktree_path).unwrap(); assert_eq!(head.branch, "feature"); assert_eq!(head.oid, sha); } #[test] fn libgit2_merge_updates_base_ref_in_both_repos() { // Ensure we hit the libgit2 path by NOT checking out the base branch in main repo let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let s = GitService::new(); // Record current main OID from both main repo and worktree repo; they should match pre-merge let before_main_repo = s.get_branch_oid(&repo_path, "main").unwrap(); let before_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap(); assert_eq!(before_main_repo, before_main_wt); // Perform merge (squash) while main repo is NOT on base branch (libgit2 path) let sha = s .merge_changes(&repo_path, &worktree_path, "feature", "main", "squash") .expect("merge should succeed via libgit2 path"); // Base branch ref advanced in both main and worktree repositories let after_main_repo = s.get_branch_oid(&repo_path, "main").unwrap(); let after_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap(); assert_eq!(after_main_repo, sha); assert_eq!(after_main_wt, sha); } #[test] fn libgit2_merge_updates_task_ref_and_feature_head_preserves_dirty() { // Hit libgit2 path (main repo not on base) and verify task ref + HEAD update safely let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let s = GitService::new(); // Make an uncommitted change in the feature worktree to ensure it's preserved write_file(&worktree_path, "dirty2.txt", "keep me\n"); // Perform merge (squash) from feature into main; this path uses libgit2 let sha = s .merge_changes(&repo_path, &worktree_path, "feature", "main", "squash") .expect("merge should succeed via libgit2 path"); // Dirty file preserved in worktree let dirty = std::fs::read_to_string(worktree_path.join("dirty2.txt")).unwrap(); assert_eq!(dirty, "keep me\n"); // Task branch (feature) updated to squash commit in both repos let feat_main_repo = s.get_branch_oid(&repo_path, "feature").unwrap(); let feat_worktree = s.get_branch_oid(&worktree_path, "feature").unwrap(); assert_eq!(feat_main_repo, sha); assert_eq!(feat_worktree, sha); // Feature worktree HEAD points to the new squash commit let head = s.get_head_info(&worktree_path).unwrap(); assert_eq!(head.branch, "feature"); assert_eq!(head.oid, sha); } #[test] fn rebase_refuses_to_abort_existing_rebase() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_conflict_repo_with_worktree(&td); // Start a rebase via GitService that will pause/conflict let svc = GitService::new(); let _ = svc .rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ) .expect_err("first rebase should error and leave in-progress state"); // Our service should refuse to proceed and not abort the user's rebase let service = GitService::new(); let res = service.rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ); assert!(res.is_err(), "should error because rebase is in progress"); // Note: We do not auto-abort; user should resolve or abort explicitly } #[test] fn rebase_fast_forwards_when_no_unique_commits() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_no_unique_feature_repo(&td); let g = GitService::new(); let before = g.get_head_info(&worktree_path).unwrap().oid; let new_base_oid = g.get_branch_oid(&repo_path, "new-base").unwrap(); let _res = g .rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ) .expect("rebase should succeed"); let after_oid = g.get_head_info(&worktree_path).unwrap().oid; assert_ne!(before, after_oid, "HEAD should move after rebase"); assert_eq!(after_oid, new_base_oid, "fast-forward onto new-base"); } #[test] fn rebase_applies_multiple_commits_onto_ahead_base() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let repo = Repository::open(&repo_path).unwrap(); // Advance new-base further checkout_branch(&repo, "new-base"); write_file(&repo_path, "base_more.txt", "nb more\n"); commit_all(&repo, "advance new-base more"); // Add another commit to feature write_file(&worktree_path, "feat2.txt", "second change\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature second commit"); // Rebase feature onto new-base let service = GitService::new(); let _ = service .rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ) .expect("rebase should succeed"); // Verify both files exist with expected content in the rebased worktree let feat = std::fs::read_to_string(worktree_path.join("feat.txt")).unwrap(); let feat2 = std::fs::read_to_string(worktree_path.join("feat2.txt")).unwrap(); assert_eq!(feat, "feat change\n"); assert_eq!(feat2, "second change\n"); } #[test] fn merge_when_base_ahead_and_feature_ahead_fails() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let repo = Repository::open(&repo_path).unwrap(); // Advance base (main) after feature was created checkout_branch(&repo, "main"); write_file(&repo_path, "base_ahead.txt", "base ahead\n"); commit_all(&repo, "base ahead commit"); // Feature adds its own file (already has feat.txt from setup) and commit another write_file(&worktree_path, "another.txt", "feature ahead\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature ahead extra"); let g = GitService::new(); let before_main = g.get_branch_oid(&repo_path, "main").unwrap(); // Attempt to merge (squash) into main - should fail because base is ahead let service = GitService::new(); let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "squash merge", ); assert!( res.is_err(), "merge should fail when base branch is ahead of task branch" ); // Verify main branch was not modified let after_main = g.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!( before_main, after_main, "main ref should remain unchanged when merge fails" ); } #[test] fn merge_conflict_does_not_move_base_ref() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_direct_conflict_repo(&td); // Record main ref before let _repo = Repository::open(&repo_path).unwrap(); let g = GitService::new(); let before = g.get_branch_oid(&repo_path, "main").unwrap(); let service = GitService::new(); let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "squash merge", ); assert!(res.is_err(), "conflicting merge should fail"); let after = g.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(before, after, "main ref must remain unchanged on conflict"); } #[test] fn merge_delete_vs_modify_conflict_behaves_safely() { // main modifies file, feature deletes it -> but now blocked by branch ahead check let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); let repo = Repository::open(&repo_path).unwrap(); // start from main with a file checkout_branch(&repo, "main"); write_file(&repo_path, "conflict_dm.txt", "base\n"); commit_all(&repo, "add conflict file"); // feature deletes it and commits let wt_repo = Repository::open(&worktree_path).unwrap(); let path = worktree_path.join("conflict_dm.txt"); if path.exists() { std::fs::remove_file(&path).unwrap(); } commit_all(&wt_repo, "delete in feature"); // main modifies same file (this puts main ahead of feature) write_file(&repo_path, "conflict_dm.txt", "main modify\n"); commit_all(&repo, "modify in main"); // Capture main state AFTER all setup commits let g = GitService::new(); let before = g.get_branch_oid(&repo_path, "main").unwrap(); let service = GitService::new(); let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "squash merge", ); // Should now fail due to base branch being ahead, not due to merge conflicts assert!(res.is_err(), "merge should fail when base branch is ahead"); // Ensure base ref unchanged on failure let after = g.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(before, after, "main ref must remain unchanged on failure"); } #[test] fn rebase_preserves_rename_changes() { // feature renames a file; rebase onto new-base preserves rename let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); // feature: rename feat.txt -> feat_renamed.txt std::fs::rename( worktree_path.join("feat.txt"), worktree_path.join("feat_renamed.txt"), ) .unwrap(); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "rename feat"); // rebase onto new-base let service = GitService::new(); let _ = service .rebase_branch( &repo_path, &worktree_path, "new-base", "old-base", "feature", ) .expect("rebase should succeed"); // after rebase, renamed file present; original absent assert!(worktree_path.join("feat_renamed.txt").exists()); assert!(!worktree_path.join("feat.txt").exists()); } #[test] fn merge_refreshes_main_worktree_when_on_base() { let td = TempDir::new().unwrap(); // Initialize repo and ensure main is checked out let repo_path = td.path().join("repo_refresh"); let s = GitService::new(); s.initialize_repo_with_main_branch(&repo_path).unwrap(); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); // Baseline file write_file(&repo_path, "file.txt", "base\n"); let _ = s.commit(&repo_path, "add base").unwrap(); // Create feature branch and worktree create_branch_from_head(&repo, "feature"); let wt = td.path().join("wt_refresh"); s.add_worktree(&repo_path, &wt, "feature", false).unwrap(); // Modify file in worktree and commit write_file(&wt, "file.txt", "feature change\n"); let _ = s.commit(&wt, "feature change").unwrap(); // Merge into main (squash) and ensure main worktree is updated since it is on base let merge_sha = s .merge_changes(&repo_path, &wt, "feature", "main", "squash") .unwrap(); // Since main is on base branch and we use safe CLI merge, both working tree // and ref should reflect the merged content. let content = std::fs::read_to_string(repo_path.join("file.txt")).unwrap(); assert_eq!(content, "feature change\n"); let oid = s.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(oid, merge_sha); } #[test] fn sparse_checkout_respected_in_worktree_diffs_and_commit() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo_sparse"); let s = GitService::new(); s.initialize_repo_with_main_branch(&repo_path).unwrap(); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); // baseline content write_file(&repo_path, "included/a.txt", "A\n"); write_file(&repo_path, "excluded/b.txt", "B\n"); let _ = s.commit(&repo_path, "baseline").unwrap(); // enable sparse-checkout for 'included' only let cli = GitCli::new(); cli.git(&repo_path, ["sparse-checkout", "init", "--cone"]) .unwrap(); cli.git(&repo_path, ["sparse-checkout", "set", "included"]) .unwrap(); // create feature branch and worktree create_branch_from_head(&repo, "feature"); let wt = td.path().join("wt_sparse"); s.add_worktree(&repo_path, &wt, "feature", false).unwrap(); // materialization check: included exists, excluded does not assert!(wt.join("included/a.txt").exists()); assert!(!wt.join("excluded/b.txt").exists()); // modify included file write_file(&wt, "included/a.txt", "A-mod\n"); let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap(); // get worktree diffs vs main, ensure excluded/b.txt is NOT reported deleted let diffs = s .get_diffs( DiffTarget::Worktree { worktree_path: Path::new(&wt), base_commit: &base_commit, }, None, ) .unwrap(); assert!( diffs .iter() .any(|d| d.new_path.as_deref() == Some("included/a.txt")) ); assert!( !diffs .iter() .any(|d| d.old_path.as_deref() == Some("excluded/b.txt") || d.new_path.as_deref() == Some("excluded/b.txt")) ); // commit and verify commit diffs also only include included/ changes let _ = s.commit(&wt, "modify included").unwrap(); let head_sha = s.get_head_info(&wt).unwrap().oid; let commit_diffs = s .get_diffs( DiffTarget::Commit { repo_path: Path::new(&wt), commit_sha: &head_sha, }, None, ) .unwrap(); assert!( commit_diffs .iter() .any(|d| d.new_path.as_deref() == Some("included/a.txt")) ); assert!( commit_diffs .iter() .all(|d| d.new_path.as_deref() != Some("excluded/b.txt") && d.old_path.as_deref() != Some("excluded/b.txt")) ); } #[test] fn worktree_diff_ignores_commits_where_base_branch_is_ahead() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo_base_ahead"); let s = GitService::new(); s.initialize_repo_with_main_branch(&repo_path).unwrap(); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); write_file(&repo_path, "shared.txt", "base\n"); let _ = s.commit(&repo_path, "add shared").unwrap(); create_branch_from_head(&repo, "feature"); let wt = td.path().join("wt_base_ahead"); s.add_worktree(&repo_path, &wt, "feature", false).unwrap(); write_file(&repo_path, "base_only.txt", "main ahead\n"); let _ = s.commit(&repo_path, "main ahead").unwrap(); write_file(&wt, "feature.txt", "feature change\n"); let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap(); let diffs = s .get_diffs( DiffTarget::Worktree { worktree_path: Path::new(&wt), base_commit: &base_commit, }, None, ) .unwrap(); assert!( diffs .iter() .any(|d| d.new_path.as_deref() == Some("feature.txt")) ); assert!(diffs.iter().all(|d| { d.new_path.as_deref() != Some("base_only.txt") && d.old_path.as_deref() != Some("base_only.txt") })); } // Helper: initialize a repo with main, configure user via service fn init_repo_only_service(root: &TempDir) -> PathBuf { let repo_path = root.path().join("repo_svc"); let s = GitService::new(); s.initialize_repo_with_main_branch(&repo_path).unwrap(); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); checkout_branch(&repo, "main"); repo_path } #[test] fn merge_binary_conflict_does_not_move_ref() { let td = TempDir::new().unwrap(); let repo_path = init_repo_only_service(&td); let repo = Repository::open(&repo_path).unwrap(); let s = GitService::new(); // seed let _ = s.commit(&repo_path, "seed").unwrap(); // create feature branch and worktree create_branch_from_head(&repo, "feature"); let worktree_path = td.path().join("wt_bin"); s.add_worktree(&repo_path, &worktree_path, "feature", false) .unwrap(); // feature adds/commits binary file let mut f = fs::File::create(worktree_path.join("bin.dat")).unwrap(); f.write_all(&[0, 1, 2, 3]).unwrap(); let _ = s.commit(&worktree_path, "feature bin").unwrap(); // main adds conflicting binary content let mut f2 = fs::File::create(repo_path.join("bin.dat")).unwrap(); f2.write_all(&[9, 8, 7, 6]).unwrap(); let _ = s.commit(&repo_path, "main bin").unwrap(); let before = s.get_branch_oid(&repo_path, "main").unwrap(); let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "merge bin"); assert!(res.is_err(), "binary conflict should fail"); let after = s.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(before, after, "main ref unchanged on conflict"); } #[test] fn merge_rename_vs_modify_conflict_does_not_move_ref() { let td = TempDir::new().unwrap(); let repo_path = init_repo_only_service(&td); let repo = Repository::open(&repo_path).unwrap(); let s = GitService::new(); // base file fs::write(repo_path.join("conflict.txt"), b"base\n").unwrap(); let _ = s.commit(&repo_path, "base").unwrap(); create_branch_from_head(&repo, "feature"); let worktree_path = td.path().join("wt_ren"); s.add_worktree(&repo_path, &worktree_path, "feature", false) .unwrap(); // feature renames file std::fs::rename( worktree_path.join("conflict.txt"), worktree_path.join("conflict_renamed.txt"), ) .unwrap(); let _ = s.commit(&worktree_path, "rename").unwrap(); // main modifies original path fs::write(repo_path.join("conflict.txt"), b"main change\n").unwrap(); let _ = s.commit(&repo_path, "modify main").unwrap(); let before = s.get_branch_oid(&repo_path, "main").unwrap(); let res = s.merge_changes( &repo_path, &worktree_path, "feature", "main", "merge rename", ); match res { Err(_) => { let after = s.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(before, after, "main unchanged on conflict"); } Ok(sha) => { // ensure main advanced and result contains either renamed or modified content let after = s.get_branch_oid(&repo_path, "main").unwrap(); assert_eq!(after, sha); let diffs = s .get_diffs( DiffTarget::Commit { repo_path: Path::new(&repo_path), commit_sha: &after, }, None, ) .unwrap(); let has_renamed = diffs .iter() .any(|d| d.new_path.as_deref() == Some("conflict_renamed.txt")); let has_modified = diffs.iter().any(|d| { d.new_path.as_deref() == Some("conflict.txt") && d.new_content.as_deref() == Some("main change\n") }); assert!(has_renamed || has_modified); } } } #[test] fn merge_leaves_no_staged_changes_on_target_branch() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); // Ensure main repo is on the base branch (triggers CLI merge path) let s = GitService::new(); let repo = Repository::open(&repo_path).unwrap(); checkout_branch(&repo, "main"); // Feature branch makes some changes write_file(&worktree_path, "feature_file.txt", "feature content\n"); write_file(&worktree_path, "common.txt", "modified by feature\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature changes"); // Perform the merge let _merge_sha = s .merge_changes( &repo_path, &worktree_path, "feature", "main", "merge feature", ) .expect("merge should succeed"); // THE KEY CHECK: Verify no staged changes remain on target branch let git_cli = GitCli::new(); let has_staged = git_cli .has_staged_changes(&repo_path) .expect("should be able to check staged changes"); assert!( !has_staged, "Target branch should have no staged changes after merge" ); // Debug info if test fails if has_staged { let status_output = git_cli.git(&repo_path, ["status", "--porcelain"]).unwrap(); panic!("Found staged changes after merge:\n{status_output}"); } } #[test] fn worktree_to_worktree_merge_leaves_no_staged_changes() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo"); let worktree_a_path = td.path().join("wt-feature-a"); let worktree_b_path = td.path().join("wt-feature-b"); // Setup: Initialize repo with main branch let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); write_file(&repo_path, "base.txt", "base content\n"); commit_all(&repo, "initial commit"); // Create two feature branches create_branch_from_head(&repo, "feature-a"); create_branch_from_head(&repo, "feature-b"); // Create worktrees for both feature branches service .add_worktree(&repo_path, &worktree_a_path, "feature-a", false) .expect("create worktree A"); service .add_worktree(&repo_path, &worktree_b_path, "feature-b", false) .expect("create worktree B"); // Make changes in worktree A write_file( &worktree_a_path, "feature_a.txt", "content from feature A\n", ); write_file(&worktree_a_path, "base.txt", "modified by feature A\n"); let wt_a_repo = Repository::open(&worktree_a_path).unwrap(); commit_all(&wt_a_repo, "feature A changes"); // Ensure main repo is on different branch (neither feature-a nor feature-b) checkout_branch(&repo, "main"); let _sha = service.merge_changes( &repo_path, &worktree_a_path, "feature-a", "feature-b", "merge feature-a into feature-b", ); // Verify no staged changes were introduced let git_cli = GitCli::new(); let has_staged_main = git_cli .has_staged_changes(&repo_path) .expect("should be able to check staged changes in main repo"); let has_staged_target = git_cli .has_staged_changes(&worktree_b_path) .expect("should be able to check staged changes in target worktree"); assert!( !has_staged_main, "Main repo should have no staged changes after failed merge" ); assert!( !has_staged_target, "Target worktree should have no staged changes after failed merge" ); } #[test] fn merge_into_orphaned_branch_uses_libgit2_fallback() { let td = TempDir::new().unwrap(); let (repo_path, worktree_path) = setup_repo_with_worktree(&td); // Create an "orphaned" target branch that exists as ref but isn't checked out anywhere let service = GitService::new(); let repo = Repository::open(&repo_path).unwrap(); // Create orphaned-feature branch from current main HEAD but don't check it out let main_commit = repo.head().unwrap().peel_to_commit().unwrap(); repo.branch("orphaned-feature", &main_commit, false) .unwrap(); // Ensure main repo is on different branch and no worktree has orphaned-feature checkout_branch(&repo, "main"); // Make changes in source worktree write_file( &worktree_path, "feature_content.txt", "content from feature\n", ); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature changes"); // orphaned-feature is not checked out anywhere, so should trigger libgit2 path // Perform merge into orphaned branch (should use libgit2 fallback) let merge_sha = service .merge_changes( &repo_path, &worktree_path, "feature", "orphaned-feature", "merge into orphaned branch", ) .expect("libgit2 merge into orphaned branch should succeed"); // Verify merge worked - orphaned-feature branch should now point to merge commit let orphaned_branch_oid = service .get_branch_oid(&repo_path, "orphaned-feature") .unwrap(); assert_eq!( orphaned_branch_oid, merge_sha, "orphaned-feature branch should point to merge commit" ); // Verify no working tree was affected (since branch wasn't checked out anywhere) let main_git_cli = GitCli::new(); let main_has_staged = main_git_cli.has_staged_changes(&repo_path).unwrap(); let worktree_has_staged = main_git_cli.has_staged_changes(&worktree_path).unwrap(); assert!( !main_has_staged, "Main repo should remain clean after libgit2 merge" ); assert!( !worktree_has_staged, "Source worktree should remain clean after libgit2 merge" ); } #[test] fn merge_base_ahead_of_task_should_error() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo"); let worktree_path = td.path().join("wt-feature"); // Setup: Initialize repo with main branch let service = GitService::new(); service .initialize_repo_with_main_branch(&repo_path) .expect("init repo"); let repo = Repository::open(&repo_path).unwrap(); configure_user(&repo); // Initial commit on main write_file(&repo_path, "base.txt", "initial content\n"); commit_all(&repo, "initial commit"); // Create feature branch from this point create_branch_from_head(&repo, "feature"); service .add_worktree(&repo_path, &worktree_path, "feature", false) .expect("create worktree"); // Feature makes a change and commits write_file(&worktree_path, "feature.txt", "feature content\n"); let wt_repo = Repository::open(&worktree_path).unwrap(); commit_all(&wt_repo, "feature change"); // Main branch advances ahead of feature (this is the key scenario) checkout_branch(&repo, "main"); write_file(&repo_path, "main_advance.txt", "main advanced\n"); commit_all(&repo, "main advances ahead"); write_file(&repo_path, "main_advance2.txt", "main advanced more\n"); commit_all(&repo, "main advances further"); // Attempt to merge feature into main when main is ahead // This should error because base branch has moved ahead of task branch let res = service.merge_changes( &repo_path, &worktree_path, "feature", "main", "attempt merge when base ahead", ); // TDD: This test will initially fail because merge currently succeeds // Later we'll fix the merge logic to detect this scenario and error assert!( res.is_err(), "Merge should error when base branch is ahead of task branch" ); } ================================================ FILE: crates/git/tests/git_workflow.rs ================================================ use std::{ fs, io::Write, path::{Path, PathBuf}, }; use git::{DiffTarget, GitCli, GitService}; use git2::{Repository, build::CheckoutBuilder}; use tempfile::TempDir; use utils::diff::DiffChangeKind; fn add_path(repo_path: &Path, path: &str) { let git = GitCli::new(); git.git(repo_path, ["add", path]).unwrap(); } fn get_commit_author(repo_path: &Path, commit_sha: &str) -> (Option, Option) { let repo = git2::Repository::open(repo_path).unwrap(); let oid = git2::Oid::from_str(commit_sha).unwrap(); let commit = repo.find_commit(oid).unwrap(); let author = commit.author(); ( author.name().map(|s| s.to_string()), author.email().map(|s| s.to_string()), ) } fn get_head_author(repo_path: &Path) -> (Option, Option) { let repo = git2::Repository::open(repo_path).unwrap(); let head = repo.head().unwrap(); let oid = head.target().unwrap(); let commit = repo.find_commit(oid).unwrap(); let author = commit.author(); ( author.name().map(|s| s.to_string()), author.email().map(|s| s.to_string()), ) } fn write_file>(base: P, rel: &str, content: &str) { let path = base.as_ref().join(rel); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } let mut f = fs::File::create(&path).unwrap(); f.write_all(content.as_bytes()).unwrap(); } fn configure_user(repo_path: &Path, name: &str, email: &str) { let repo = git2::Repository::open(repo_path).unwrap(); let mut cfg = repo.config().unwrap(); cfg.set_str("user.name", name).unwrap(); cfg.set_str("user.email", email).unwrap(); } fn init_repo_main(root: &TempDir) -> PathBuf { let path = root.path().join("repo"); let s = GitService::new(); s.initialize_repo_with_main_branch(&path).unwrap(); configure_user(&path, "Test User", "test@example.com"); checkout_branch(&path, "main"); path } fn checkout_branch(repo_path: &Path, name: &str) { let repo = Repository::open(repo_path).unwrap(); repo.set_head(&format!("refs/heads/{name}")).unwrap(); let mut co = CheckoutBuilder::new(); co.force(); repo.checkout_head(Some(&mut co)).unwrap(); } fn create_branch(repo_path: &Path, name: &str) { let repo = Repository::open(repo_path).unwrap(); let head = repo.head().unwrap().peel_to_commit().unwrap(); let _ = repo.branch(name, &head, true).unwrap(); } #[test] fn commit_empty_message_behaviour() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); write_file(&repo_path, "x.txt", "x\n"); let s = GitService::new(); let res = s.commit(&repo_path, ""); // Some environments disallow empty commit messages by default. // Accept either success or a clear error. if let Err(e) = &res { let msg = format!("{e}"); assert!(msg.contains("empty commit message") || msg.contains("git commit failed")); } } fn has_global_git_identity() -> bool { if let Ok(cfg) = git2::Config::open_default() { let has_name = cfg.get_string("user.name").is_ok(); let has_email = cfg.get_string("user.email").is_ok(); return has_name && has_email; } false } #[test] fn initialize_repo_without_user_creates_initial_commit() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo_no_user_init"); let s = GitService::new(); // No configure_user call; rely on fallback signature for initial commit s.initialize_repo_with_main_branch(&repo_path).unwrap(); let head = s.get_head_info(&repo_path).unwrap(); assert_eq!(head.branch, "main"); assert!(!head.oid.is_empty()); // Verify author is set: either global identity (if configured) or fallback let (name, email) = get_head_author(&repo_path); if has_global_git_identity() { assert!(name.is_some() && email.is_some()); } else { assert_eq!(name.as_deref(), Some("Vibe Kanban")); assert_eq!(email.as_deref(), Some("noreply@vibekanban.com")); } } #[test] fn commit_without_user_config_succeeds() { let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo_no_user"); let s = GitService::new(); s.initialize_repo_with_main_branch(&repo_path).unwrap(); write_file(&repo_path, "f.txt", "x\n"); // No configure_user call here let res = s.commit(&repo_path, "no user config"); assert!(res.is_ok()); } #[test] fn commit_fails_when_index_locked() { use std::fs::File; let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); write_file(&repo_path, "y.txt", "y\n"); // Simulate index lock let git_dir = repo_path.join(".git"); let _lock = File::create(git_dir.join("index.lock")).unwrap(); let s = GitService::new(); let res = s.commit(&repo_path, "should fail"); assert!(res.is_err()); } #[test] fn staged_but_uncommitted_changes_is_dirty() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // seed tracked file write_file(&repo_path, "t1.txt", "a\n"); let _ = s.commit(&repo_path, "seed").unwrap(); // modify and stage write_file(&repo_path, "t1.txt", "b\n"); add_path(&repo_path, "t1.txt"); assert!(!s.is_worktree_clean(&repo_path).unwrap()); } #[test] fn worktree_clean_detects_staged_deleted_and_renamed() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); write_file(&repo_path, "t1.txt", "1\n"); write_file(&repo_path, "t2.txt", "2\n"); let s = GitService::new(); let _ = s.commit(&repo_path, "seed").unwrap(); // delete tracked file std::fs::remove_file(repo_path.join("t2.txt")).unwrap(); assert!(!s.is_worktree_clean(&repo_path).unwrap()); // restore and test rename write_file(&repo_path, "t2.txt", "2\n"); let _ = s.commit(&repo_path, "restore t2").unwrap(); std::fs::rename(repo_path.join("t2.txt"), repo_path.join("t2-renamed.txt")).unwrap(); assert!(!s.is_worktree_clean(&repo_path).unwrap()); } #[test] fn diff_added_binary_file_has_no_content() { // ensure binary file content is not loaded (null byte guard) let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); // base let s = GitService::new(); let _ = s.commit(&repo_path, "base").unwrap(); // branch with binary file create_branch(&repo_path, "feature"); checkout_branch(&repo_path, "feature"); // write binary with null byte let mut f = fs::File::create(repo_path.join("bin.dat")).unwrap(); f.write_all(&[0u8, 1, 2, 3]).unwrap(); let _ = s.commit(&repo_path, "add binary").unwrap(); let s = GitService::new(); let diffs = s .get_diffs( DiffTarget::Branch { repo_path: Path::new(&repo_path), branch_name: "feature", base_branch: "main", }, None, ) .unwrap(); let bin = diffs .iter() .find(|d| d.new_path.as_deref() == Some("bin.dat")) .expect("binary diff present"); assert!(bin.new_content.is_none()); } #[test] fn initialize_and_default_branch_and_head_info() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // Head info branch should be main let head = s.get_head_info(&repo_path).unwrap(); assert_eq!(head.branch, "main"); // Repo has an initial commit (OID parsable) assert!(!head.oid.is_empty()); } #[test] fn commit_and_is_worktree_clean() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); write_file(&repo_path, "foo.txt", "hello\n"); let s = GitService::new(); let committed = s.commit(&repo_path, "add foo").unwrap(); assert!(committed); assert!(s.is_worktree_clean(&repo_path).unwrap()); // Verify commit contains file let diffs = s .get_diffs( DiffTarget::Commit { repo_path: Path::new(&repo_path), commit_sha: &s.get_head_info(&repo_path).unwrap().oid, }, None, ) .unwrap(); assert!( diffs .iter() .any(|d| d.new_path.as_deref() == Some("foo.txt")) ); } #[test] fn commit_in_detached_head_succeeds_via_service() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); // initial parent write_file(&repo_path, "a.txt", "a\n"); let s = GitService::new(); let _ = s.commit(&repo_path, "add a").unwrap(); // detach via service let repo = git2::Repository::open(&repo_path).unwrap(); let oid = repo.head().unwrap().target().unwrap(); repo.set_head_detached(oid).unwrap(); // commit while detached write_file(&repo_path, "b.txt", "b\n"); let ok = s.commit(&repo_path, "detached commit").unwrap(); assert!(ok); } #[test] fn branch_status_ahead_and_behind() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // main: initial commit write_file(&repo_path, "base.txt", "base\n"); let _ = s.commit(&repo_path, "base").unwrap(); // create feature from main create_branch(&repo_path, "feature"); // advance feature by 1 checkout_branch(&repo_path, "feature"); write_file(&repo_path, "feature.txt", "f1\n"); let _ = s.commit(&repo_path, "f1").unwrap(); // advance main by 1 checkout_branch(&repo_path, "main"); write_file(&repo_path, "main.txt", "m1\n"); let _ = s.commit(&repo_path, "m1").unwrap(); let s = GitService::new(); let (ahead, behind) = s.get_branch_status(&repo_path, "feature", "main").unwrap(); assert_eq!((ahead, behind), (1, 1)); // advance feature by one more (ahead 2, behind 1) checkout_branch(&repo_path, "feature"); write_file(&repo_path, "feature2.txt", "f2\n"); let _ = s.commit(&repo_path, "f2").unwrap(); let (ahead2, behind2) = s.get_branch_status(&repo_path, "feature", "main").unwrap(); assert_eq!((ahead2, behind2), (2, 1)); } #[test] fn get_all_branches_lists_current_and_others() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); create_branch(&repo_path, "feature"); let s = GitService::new(); let branches = s.get_all_branches(&repo_path).unwrap(); let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect(); assert!(names.contains(&"main")); assert!(names.contains(&"feature")); // current should be main let main_entry = branches.iter().find(|b| b.name == "main").unwrap(); assert!(main_entry.is_current); } #[test] fn get_branch_diffs_between_branches() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // base commit on main write_file(&repo_path, "a.txt", "a\n"); let _ = s.commit(&repo_path, "add a").unwrap(); // create branch and add new file create_branch(&repo_path, "feature"); checkout_branch(&repo_path, "feature"); write_file(&repo_path, "b.txt", "b\n"); let _ = s.commit(&repo_path, "add b").unwrap(); let s = GitService::new(); let diffs = s .get_diffs( DiffTarget::Branch { repo_path: Path::new(&repo_path), branch_name: "feature", base_branch: "main", }, None, ) .unwrap(); assert!(diffs.iter().any(|d| d.new_path.as_deref() == Some("b.txt"))); } #[test] fn worktree_diff_respects_path_filter() { // Use git CLI status diff under the hood let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); // main baseline write_file(&repo_path, "src/keep.txt", "k\n"); write_file(&repo_path, "other/skip.txt", "s\n"); let s = GitService::new(); let _ = s.commit(&repo_path, "baseline").unwrap(); // create feature and work in place (worktree is repo_path) create_branch(&repo_path, "feature"); // modify files without committing write_file(&repo_path, "src/only.txt", "only\n"); write_file(&repo_path, "other/skip2.txt", "skip\n"); let s = GitService::new(); let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap(); let diffs = s .get_diffs( DiffTarget::Worktree { worktree_path: Path::new(&repo_path), base_commit: &base_commit, }, Some(&["src"]), ) .unwrap(); assert!( diffs .iter() .any(|d| d.new_path.as_deref() == Some("src/only.txt")) ); assert!( !diffs .iter() .any(|d| d.new_path.as_deref() == Some("other/skip2.txt")) ); } #[test] fn get_branch_oid_nonexistent_errors() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); let res = s.get_branch_oid(&repo_path, "no-such-branch"); assert!(res.is_err()); } #[test] fn create_unicode_branch_and_list() { let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // base commit write_file(&repo_path, "file.txt", "ok\n"); let _ = s.commit(&repo_path, "base"); // unicode/slash branch name (valid ref) let bname = "feature/ünicode"; create_branch(&repo_path, bname); let names: Vec<_> = s .get_all_branches(&repo_path) .unwrap() .into_iter() .map(|b| b.name) .collect(); assert!(names.iter().any(|n| n == bname)); } #[cfg(unix)] #[test] fn worktree_diff_permission_only_change() { use std::os::unix::fs::PermissionsExt; let td = TempDir::new().unwrap(); let repo_path = init_repo_main(&td); let s = GitService::new(); // baseline commit write_file(&repo_path, "p.sh", "echo hi\n"); let _ = s.commit(&repo_path, "add p.sh").unwrap(); // create a feature branch baseline at HEAD create_branch(&repo_path, "feature"); // change only the permission (chmod +x) let mut perms = std::fs::metadata(repo_path.join("p.sh")) .unwrap() .permissions(); perms.set_mode(perms.mode() | 0o111); std::fs::set_permissions(repo_path.join("p.sh"), perms).unwrap(); let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap(); // Compute worktree diff vs main on feature let diffs = s .get_diffs( DiffTarget::Worktree { worktree_path: Path::new(&repo_path), base_commit: &base_commit, }, None, ) .unwrap(); let d = diffs .into_iter() .find(|d| d.new_path.as_deref() == Some("p.sh")) .expect("p.sh diff present"); assert!(matches!(d.change, DiffChangeKind::PermissionChange)); assert_eq!(d.old_content, d.new_content); } #[test] fn squash_merge_libgit2_sets_author_without_user() { // Verify merge_changes (libgit2 path) uses fallback author when no config exists use git2::Repository; let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo_fallback_merge"); let worktree_path = td.path().join("wt_feature"); let s = GitService::new(); // Init repo without user config s.initialize_repo_with_main_branch(&repo_path).unwrap(); // Create feature branch and worktree create_branch(&repo_path, "feature"); s.add_worktree(&repo_path, &worktree_path, "feature", false) .unwrap(); // Make a feature commit in the worktree via libgit2 using an explicit signature write_file(&worktree_path, "f.txt", "feat\n"); { let repo = Repository::open(&worktree_path).unwrap(); // stage all let mut index = repo.index().unwrap(); index .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) .unwrap(); index.write().unwrap(); let tree_id = index.write_tree().unwrap(); let tree = repo.find_tree(tree_id).unwrap(); let sig = git2::Signature::now("Other Author", "other@example.com").unwrap(); let parent = repo.head().unwrap().peel_to_commit().unwrap(); let _cid = repo .commit(Some("HEAD"), &sig, &sig, "feat", &tree, &[&parent]) .unwrap(); } // Ensure main repo is NOT on base branch so merge_changes takes libgit2 path create_branch(&repo_path, "dev"); checkout_branch(&repo_path, "dev"); // Merge feature -> main (libgit2 squash) let merge_sha = s .merge_changes(&repo_path, &worktree_path, "feature", "main", "squash") .unwrap(); // The squash commit author should not be the feature commit's author, and must be present. let (name, email) = get_commit_author(&repo_path, &merge_sha); assert_ne!(name.as_deref(), Some("Other Author")); assert_ne!(email.as_deref(), Some("other@example.com")); if has_global_git_identity() { assert!(name.is_some() && email.is_some()); } else { assert_eq!(name.as_deref(), Some("Vibe Kanban")); assert_eq!(email.as_deref(), Some("noreply@vibekanban.com")); } } ================================================ FILE: crates/git-host/Cargo.toml ================================================ [package] name = "git-host" version = "0.1.33" edition = "2024" [dependencies] async-trait = { workspace = true } backon = "1.5.1" chrono = { version = "0.4", features = ["serde"] } db = { path = "../db" } enum_dispatch = "0.3.13" serde = { workspace = true } serde_json = { workspace = true } tempfile = "3.21" thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } ts-rs = { workspace = true } url = "2.5" utils = { path = "../utils" } ================================================ FILE: crates/git-host/src/azure/cli.rs ================================================ //! Minimal helpers around the Azure CLI (`az repos`). //! //! This module provides low-level access to the Azure CLI for Azure DevOps //! repository and pull request operations. use std::{ ffi::{OsStr, OsString}, path::Path, process::Command, }; use chrono::{DateTime, Utc}; use db::models::merge::{MergeStatus, PullRequestInfo}; use serde::Deserialize; use thiserror::Error; use utils::{command_ext::NoWindowExt, shell::resolve_executable_path_blocking}; use crate::types::{CreatePrRequest, UnifiedPrComment}; #[derive(Debug, Clone)] pub struct AzureRepoInfo { pub organization_url: String, pub project: String, pub project_id: String, pub repo_name: String, pub repo_id: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzPrResponse { pull_request_id: i64, status: Option, closed_date: Option, repository: Option, last_merge_commit: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzRepository { web_url: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzCommit { commit_id: Option, } #[derive(Deserialize)] struct AzThreadsResponse { value: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzThread { comments: Option>, thread_context: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzThreadContext { file_path: Option, right_file_start: Option, } #[derive(Deserialize)] struct AzFilePosition { line: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzThreadComment { id: Option, author: Option, content: Option, published_date: Option, comment_type: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzAuthor { display_name: Option, } /// Response item from `az repos list` #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AzRepoListItem { id: String, name: String, project: AzRepoProject, remote_url: String, ssh_url: Option, } #[derive(Deserialize)] struct AzRepoProject { id: String, name: String, } #[derive(Debug, Error)] pub enum AzCliError { #[error("Azure CLI (`az`) executable not found or not runnable")] NotAvailable, #[error("Azure CLI command failed: {0}")] CommandFailed(String), #[error("Azure CLI authentication failed: {0}")] AuthFailed(String), #[error("Azure CLI returned unexpected output: {0}")] UnexpectedOutput(String), } #[derive(Debug, Clone, Default)] pub struct AzCli; impl AzCli { pub fn new() -> Self { Self {} } /// Ensure the Azure CLI binary is discoverable. fn ensure_available(&self) -> Result<(), AzCliError> { resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; Ok(()) } fn run(&self, args: I, dir: Option<&Path>) -> Result where I: IntoIterator, S: AsRef, { self.ensure_available()?; let az = resolve_executable_path_blocking("az").ok_or(AzCliError::NotAvailable)?; let mut cmd = Command::new(&az); if let Some(d) = dir { cmd.current_dir(d); } for arg in args { cmd.arg(arg); } tracing::debug!("Running Azure CLI command: {:?} {:?}", az, cmd.get_args()); let output = cmd .no_window() .output() .map_err(|err| AzCliError::CommandFailed(err.to_string()))?; if output.status.success() { return Ok(String::from_utf8_lossy(&output.stdout).to_string()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); // Check for authentication errors let lower = stderr.to_ascii_lowercase(); if lower.contains("az login") || lower.contains("not logged in") || lower.contains("authentication") || lower.contains("unauthorized") || lower.contains("credentials") || lower.contains("please run 'az login'") { return Err(AzCliError::AuthFailed(stderr)); } Err(AzCliError::CommandFailed(stderr)) } pub fn get_repo_info( &self, repo_path: &Path, remote_url: &str, ) -> Result { let raw = self.run( ["repos", "list", "--detect", "true", "--output", "json"], Some(repo_path), )?; let repos: Vec = serde_json::from_str(raw.trim()).map_err(|e| { AzCliError::UnexpectedOutput(format!("Failed to parse repos list: {e}; raw: {raw}")) })?; // Find the repo that matches our remote URL (check both HTTPS and SSH) let is_ssh = remote_url.starts_with("git@") || remote_url.starts_with("ssh://"); let repo = repos .into_iter() .find(|r| { if is_ssh { r.ssh_url .as_ref() .map(|ssh| Self::urls_match(ssh, remote_url)) .unwrap_or(false) } else { Self::urls_match(&r.remote_url, remote_url) } }) .ok_or_else(|| { AzCliError::UnexpectedOutput(format!( "No repo found matching remote URL: {}", remote_url )) })?; let organization_url = Self::extract_organization_url(&repo.remote_url).ok_or_else(|| { AzCliError::UnexpectedOutput(format!( "Could not extract organization URL from: {}", repo.remote_url )) })?; tracing::debug!( "Got Azure DevOps repo info: org_url='{}', project='{}' ({}), repo='{}' ({})", organization_url, repo.project.name, repo.project.id, repo.name, repo.id ); Ok(AzureRepoInfo { organization_url, project: repo.project.name, project_id: repo.project.id, repo_name: repo.name, repo_id: repo.id, }) } fn urls_match(url1: &str, url2: &str) -> bool { let normalize = |url: &str| { let mut s = url.to_lowercase(); // Normalize ssh:// prefix to scp-style if let Some(rest) = s.strip_prefix("ssh://") { s = rest.to_string(); } s.trim_end_matches('/').trim_end_matches(".git").to_string() }; normalize(url1) == normalize(url2) } /// Extract the organization URL from a remote URL. /// Returns the base URL that can be used with Azure CLI commands. fn extract_organization_url(url: &str) -> Option { // dev.azure.com format: https://dev.azure.com/{org}/... -> https://dev.azure.com/{org} if url.contains("dev.azure.com") { let parts: Vec<&str> = url.split('/').collect(); let azure_idx = parts.iter().position(|&p| p.contains("dev.azure.com"))?; let org = parts.get(azure_idx + 1)?; return Some(format!("https://dev.azure.com/{}", org)); } // Legacy format: https://{org}.visualstudio.com/... -> https://{org}.visualstudio.com if url.contains(".visualstudio.com") { let parts: Vec<&str> = url.split('/').collect(); for part in parts.iter() { if part.contains(".visualstudio.com") { return Some(format!("https://{}", part)); } } } None } pub fn create_pr( &self, request: &CreatePrRequest, organization_url: &str, project: &str, repo_name: &str, ) -> Result { let body = request.body.as_deref().unwrap_or(""); let mut args: Vec = Vec::with_capacity(20); args.push(OsString::from("repos")); args.push(OsString::from("pr")); args.push(OsString::from("create")); args.push(OsString::from("--organization")); args.push(OsString::from(organization_url)); args.push(OsString::from("--project")); args.push(OsString::from(project)); args.push(OsString::from("--repository")); args.push(OsString::from(repo_name)); args.push(OsString::from("--source-branch")); args.push(OsString::from(&request.head_branch)); args.push(OsString::from("--target-branch")); args.push(OsString::from(&request.base_branch)); args.push(OsString::from("--title")); args.push(OsString::from(&request.title)); args.push(OsString::from("--description")); args.push(OsString::from(body)); args.push(OsString::from("--output")); args.push(OsString::from("json")); if request.draft.unwrap_or(false) { args.push(OsString::from("--draft")); } let raw = self.run(args, None)?; Self::parse_pr_response(&raw) } pub fn view_pr(&self, pr_url: &str) -> Result { let (organization, pr_id) = Self::parse_pr_url(pr_url).ok_or_else(|| { AzCliError::UnexpectedOutput(format!("Could not parse Azure DevOps PR URL: {pr_url}")) })?; let org_url = format!("https://dev.azure.com/{}", organization); let raw = self.run( [ "repos", "pr", "show", "--id", &pr_id.to_string(), "--organization", &org_url, "--output", "json", ], None, )?; Self::parse_pr_response(&raw) } pub fn list_prs_for_branch( &self, organization_url: &str, project: &str, repo_name: &str, branch: &str, ) -> Result, AzCliError> { let raw = self.run( [ "repos", "pr", "list", "--organization", organization_url, "--project", project, "--repository", repo_name, "--source-branch", branch, "--status", "all", "--output", "json", ], None, )?; Self::parse_pr_list_response(&raw) } pub fn get_pr_threads( &self, organization_url: &str, project_id: &str, repo_id: &str, pr_id: i64, ) -> Result, AzCliError> { let mut args: Vec = Vec::with_capacity(16); args.push(OsString::from("devops")); args.push(OsString::from("invoke")); args.push(OsString::from("--area")); args.push(OsString::from("git")); args.push(OsString::from("--resource")); args.push(OsString::from("pullRequestThreads")); args.push(OsString::from("--route-parameters")); args.push(OsString::from(format!("project={}", project_id))); args.push(OsString::from(format!("repositoryId={}", repo_id))); args.push(OsString::from(format!("pullRequestId={}", pr_id))); args.push(OsString::from("--organization")); args.push(OsString::from(organization_url)); args.push(OsString::from("--api-version")); args.push(OsString::from("7.0")); args.push(OsString::from("--output")); args.push(OsString::from("json")); let raw = self.run(args, None)?; Self::parse_pr_threads(&raw) } /// Parse PR URL to extract organization and PR ID. /// /// Only extracts the minimal info needed for `az repos pr show`. /// Format: `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}` pub fn parse_pr_url(url: &str) -> Option<(String, i64)> { let url_lower = url.to_lowercase(); if url_lower.contains("dev.azure.com") && url_lower.contains("/pullrequest/") { let parts: Vec<&str> = url.split('/').collect(); if let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") && parts.len() > pr_idx + 1 { let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; if let Some(azure_idx) = parts.iter().position(|&p| p.contains("dev.azure.com")) && parts.len() > azure_idx + 1 { let organization = parts[azure_idx + 1].to_string(); return Some((organization, pr_id)); } } } // Legacy format: https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id} if url_lower.contains(".visualstudio.com") && url_lower.contains("/pullrequest/") { let parts: Vec<&str> = url.split('/').collect(); for part in parts.iter() { if let Some(org) = part.strip_suffix(".visualstudio.com") && let Some(pr_idx) = parts.iter().position(|&p| p == "pullrequest") && parts.len() > pr_idx + 1 { let pr_id: i64 = parts[pr_idx + 1].parse().ok()?; return Some((org.to_string(), pr_id)); } } } None } } impl AzCli { /// Parse PR response from Azure CLI. /// Works for both `az repos pr create` and `az repos pr show`. fn parse_pr_response(raw: &str) -> Result { let pr: AzPrResponse = serde_json::from_str(raw.trim()).map_err(|e| { AzCliError::UnexpectedOutput(format!("Failed to parse PR response: {e}; raw: {raw}")) })?; Ok(Self::az_pr_to_info(pr)) } fn parse_pr_list_response(raw: &str) -> Result, AzCliError> { let prs: Vec = serde_json::from_str(raw.trim()).map_err(|e| { AzCliError::UnexpectedOutput(format!("Failed to parse PR list: {e}; raw: {raw}")) })?; Ok(prs.into_iter().map(Self::az_pr_to_info).collect()) } /// Convert Azure PR response to PullRequestInfo. fn az_pr_to_info(pr: AzPrResponse) -> PullRequestInfo { let url = pr .repository .and_then(|r| r.web_url) .map(|u| format!("{}/pullrequest/{}", u, pr.pull_request_id)) .unwrap_or_else(|| format!("pullrequest/{}", pr.pull_request_id)); let status = pr.status.as_deref().unwrap_or("active"); let merged_at = pr .closed_date .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)); let merge_commit_sha = pr.last_merge_commit.and_then(|c| c.commit_id); PullRequestInfo { number: pr.pull_request_id, url, status: Self::map_azure_status(status), merged_at, merge_commit_sha, } } fn parse_pr_threads(raw: &str) -> Result, AzCliError> { // REST API returns { "value": [...threads...] } wrapper let response: AzThreadsResponse = serde_json::from_str(raw.trim()).map_err(|e| { AzCliError::UnexpectedOutput(format!("Failed to parse threads: {e}; raw: {raw}")) })?; let threads = response.value; let mut comments = Vec::new(); for thread in threads { let file_path = thread .thread_context .as_ref() .and_then(|c| c.file_path.clone()); let line = thread .thread_context .as_ref() .and_then(|c| c.right_file_start.as_ref()) .and_then(|p| p.line); if let Some(thread_comments) = thread.comments { for c in thread_comments { // Skip system-generated comments if c.comment_type.as_deref() == Some("system") { continue; } let id = c.id.unwrap_or(0); let author = c .author .and_then(|a| a.display_name) .unwrap_or_else(|| "unknown".to_string()); let body = c.content.unwrap_or_default(); let created_at = c .published_date .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(Utc::now); if let Some(ref path) = file_path { comments.push(UnifiedPrComment::Review { id, author, author_association: None, body, created_at, url: None, path: path.clone(), line, side: None, diff_hunk: None, }); } else { comments.push(UnifiedPrComment::General { id: id.to_string(), author, author_association: None, body, created_at, url: None, }); } } } } comments.sort_by_key(|c| c.created_at()); Ok(comments) } /// Map Azure DevOps PR status to MergeStatus fn map_azure_status(status: &str) -> MergeStatus { match status.to_lowercase().as_str() { "active" => MergeStatus::Open, "completed" => MergeStatus::Merged, "abandoned" => MergeStatus::Closed, _ => MergeStatus::Unknown, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_pr_url() { // dev.azure.com format let (org, id) = AzCli::parse_pr_url( "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123", ) .unwrap(); assert_eq!(org, "myorg"); assert_eq!(id, 123); } #[test] fn test_parse_pr_url_visualstudio() { // Legacy visualstudio.com format let (org, id) = AzCli::parse_pr_url( "https://myorg.visualstudio.com/myproject/_git/myrepo/pullrequest/456", ) .unwrap(); assert_eq!(org, "myorg"); assert_eq!(id, 456); } #[test] fn test_parse_pr_url_invalid() { // GitHub URL should return None assert!(AzCli::parse_pr_url("https://github.com/owner/repo/pull/123").is_none()); // Missing pullrequest path assert!(AzCli::parse_pr_url("https://dev.azure.com/myorg/myproject/_git/myrepo").is_none()); } #[test] fn test_map_azure_status() { assert!(matches!( AzCli::map_azure_status("active"), MergeStatus::Open )); assert!(matches!( AzCli::map_azure_status("completed"), MergeStatus::Merged )); assert!(matches!( AzCli::map_azure_status("abandoned"), MergeStatus::Closed )); assert!(matches!( AzCli::map_azure_status("unknown"), MergeStatus::Unknown )); } #[test] fn test_urls_match() { // Exact match assert!(AzCli::urls_match( "https://dev.azure.com/myorg/myproject/_git/myrepo", "https://dev.azure.com/myorg/myproject/_git/myrepo" )); // Trailing slash assert!(AzCli::urls_match( "https://dev.azure.com/myorg/myproject/_git/myrepo/", "https://dev.azure.com/myorg/myproject/_git/myrepo" )); // .git suffix assert!(AzCli::urls_match( "https://dev.azure.com/myorg/myproject/_git/myrepo.git", "https://dev.azure.com/myorg/myproject/_git/myrepo" )); // Case insensitive assert!(AzCli::urls_match( "https://dev.azure.com/MyOrg/MyProject/_git/MyRepo", "https://dev.azure.com/myorg/myproject/_git/myrepo" )); // Different repos should not match assert!(!AzCli::urls_match( "https://dev.azure.com/myorg/myproject/_git/repo1", "https://dev.azure.com/myorg/myproject/_git/repo2" )); // SSH URLs assert!(AzCli::urls_match( "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" )); // SSH URL with ssh:// prefix should match scp-style assert!(AzCli::urls_match( "ssh://git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo" )); } #[test] fn test_extract_organization_url_dev_azure() { let org_url = AzCli::extract_organization_url("https://dev.azure.com/myorg/myproject/_git/myrepo") .unwrap(); assert_eq!(org_url, "https://dev.azure.com/myorg"); } #[test] fn test_extract_organization_url_visualstudio() { let org_url = AzCli::extract_organization_url("https://myorg.visualstudio.com/myproject/_git/myrepo") .unwrap(); assert_eq!(org_url, "https://myorg.visualstudio.com"); } #[test] fn test_extract_organization_url_invalid() { assert!(AzCli::extract_organization_url("https://github.com/owner/repo").is_none()); } } ================================================ FILE: crates/git-host/src/azure/mod.rs ================================================ //! Azure DevOps hosting service implementation. mod cli; use std::{path::Path, time::Duration}; use async_trait::async_trait; use backon::{ExponentialBuilder, Retryable}; pub use cli::AzCli; use cli::{AzCliError, AzureRepoInfo}; use db::models::merge::PullRequestInfo; use tokio::task; use tracing::info; use crate::{ GitHostProvider, types::{CreatePrRequest, GitHostError, OpenPrInfo, ProviderKind, UnifiedPrComment}, }; #[derive(Debug, Clone)] pub struct AzureDevOpsProvider { az_cli: AzCli, } impl AzureDevOpsProvider { pub fn new() -> Result { Ok(Self { az_cli: AzCli::new(), }) } async fn get_repo_info( &self, repo_path: &Path, remote_url: &str, ) -> Result { let cli = self.az_cli.clone(); let path = repo_path.to_path_buf(); let url = remote_url.to_string(); task::spawn_blocking(move || cli.get_repo_info(&path, &url)) .await .map_err(|err| GitHostError::Repository(format!("Failed to get repo info: {err}")))? .map_err(Into::into) } } impl From for GitHostError { fn from(error: AzCliError) -> Self { match &error { AzCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), AzCliError::NotAvailable => GitHostError::CliNotInstalled { provider: ProviderKind::AzureDevOps, }, AzCliError::CommandFailed(msg) => { let lower = msg.to_ascii_lowercase(); if lower.contains("403") || lower.contains("forbidden") { GitHostError::InsufficientPermissions(msg.clone()) } else if lower.contains("404") || lower.contains("not found") { GitHostError::RepoNotFoundOrNoAccess(msg.clone()) } else if lower.contains("not a git repository") { GitHostError::NotAGitRepository(msg.clone()) } else { GitHostError::PullRequest(msg.clone()) } } AzCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), } } } #[async_trait] impl GitHostProvider for AzureDevOpsProvider { async fn create_pr( &self, repo_path: &Path, remote_url: &str, request: &CreatePrRequest, ) -> Result { if let Some(head_url) = &request.head_repo_url && head_url != remote_url { return Err(GitHostError::PullRequest( "Cross-fork pull requests are not supported for Azure DevOps".to_string(), )); } let repo_info = self.get_repo_info(repo_path, remote_url).await?; (|| async { let cli = self.az_cli.clone(); let request_clone = request.clone(); let organization_url = repo_info.organization_url.clone(); let project = repo_info.project.clone(); let repo_name = repo_info.repo_name.clone(); let cli_result = task::spawn_blocking(move || { cli.create_pr(&request_clone, &organization_url, &project, &repo_name) }) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute Azure CLI for PR creation: {err}" )) })? .map_err(GitHostError::from)?; info!( "Created Azure DevOps PR #{} for branch {}", cli_result.number, request.head_branch ); Ok(cli_result) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "Azure DevOps API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn get_pr_status(&self, pr_url: &str) -> Result { (|| async { let cli = self.az_cli.clone(); let url = pr_url.to_string(); let pr = task::spawn_blocking(move || cli.view_pr(&url)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute Azure CLI for viewing PR: {err}" )) })?; pr.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|err: &GitHostError| err.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "Azure DevOps API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn list_prs_for_branch( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result, GitHostError> { let repo_info = self.get_repo_info(repo_path, remote_url).await?; (|| async { let cli = self.az_cli.clone(); let organization_url = repo_info.organization_url.clone(); let project = repo_info.project.clone(); let repo_name = repo_info.repo_name.clone(); let branch = branch_name.to_string(); let prs = task::spawn_blocking(move || { cli.list_prs_for_branch(&organization_url, &project, &repo_name, &branch) }) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute Azure CLI for listing PRs: {err}" )) })?; prs.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "Azure DevOps API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn get_pr_comments( &self, repo_path: &Path, remote_url: &str, pr_number: i64, ) -> Result, GitHostError> { let repo_info = self.get_repo_info(repo_path, remote_url).await?; (|| async { let cli = self.az_cli.clone(); let organization_url = repo_info.organization_url.clone(); let project_id = repo_info.project_id.clone(); let repo_id = repo_info.repo_id.clone(); let comments = task::spawn_blocking(move || { cli.get_pr_threads(&organization_url, &project_id, &repo_id, pr_number) }) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute Azure CLI for fetching PR comments: {err}" )) })?; comments.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "Azure DevOps API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn list_open_prs( &self, _repo_path: &Path, _remote_url: &str, ) -> Result, GitHostError> { // TODO: Implement list_open_prs for Azure DevOps Err(GitHostError::UnsupportedProvider) } fn provider_kind(&self) -> ProviderKind { ProviderKind::AzureDevOps } } ================================================ FILE: crates/git-host/src/detection.rs ================================================ //! Git hosting provider detection from repository URLs. use crate::types::ProviderKind; /// Detect the git hosting provider from a remote URL. /// /// Supports: /// - GitHub.com: `https://github.com/owner/repo` or `git@github.com:owner/repo.git` /// - GitHub Enterprise: URLs containing `github.` (e.g., `https://github.company.com/owner/repo`) /// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo` or legacy `https://org.visualstudio.com/...` pub fn detect_provider_from_url(url: &str) -> ProviderKind { let url_lower = url.to_lowercase(); if url_lower.contains("github.com") { return ProviderKind::GitHub; } // Check Azure patterns before GHE to avoid false positives if url_lower.contains("dev.azure.com") || url_lower.contains(".visualstudio.com") || url_lower.contains("ssh.dev.azure.com") { return ProviderKind::AzureDevOps; } // /_git/ is unique to Azure DevOps if url_lower.contains("/_git/") { return ProviderKind::AzureDevOps; } // GitHub Enterprise (contains "github." but not the Azure patterns above) if url_lower.contains("github.") { return ProviderKind::GitHub; } ProviderKind::Unknown } /// Detect the git hosting provider from a PR URL. /// /// Supports: /// - GitHub: `https://github.com/owner/repo/pull/123` /// - GitHub Enterprise: `https://github.company.com/owner/repo/pull/123` /// - Azure DevOps: `https://dev.azure.com/org/project/_git/repo/pullrequest/123` #[cfg(test)] fn detect_provider_from_pr_url(pr_url: &str) -> ProviderKind { let url_lower = pr_url.to_lowercase(); // GitHub pattern: contains /pull/ in the path if url_lower.contains("/pull/") { // Could be github.com or GHE if url_lower.contains("github.com") || url_lower.contains("github.") { return ProviderKind::GitHub; } } // Azure DevOps pattern: contains /pullrequest/ in the path if url_lower.contains("/pullrequest/") { return ProviderKind::AzureDevOps; } // Fall back to general URL detection detect_provider_from_url(pr_url) } #[cfg(test)] mod tests { use super::*; #[test] fn test_github_com_https() { assert_eq!( detect_provider_from_url("https://github.com/owner/repo"), ProviderKind::GitHub ); assert_eq!( detect_provider_from_url("https://github.com/owner/repo.git"), ProviderKind::GitHub ); } #[test] fn test_github_com_ssh() { assert_eq!( detect_provider_from_url("git@github.com:owner/repo.git"), ProviderKind::GitHub ); } #[test] fn test_github_enterprise() { assert_eq!( detect_provider_from_url("https://github.company.com/owner/repo"), ProviderKind::GitHub ); assert_eq!( detect_provider_from_url("https://github.acme.corp/team/project"), ProviderKind::GitHub ); assert_eq!( detect_provider_from_url("git@github.internal.io:org/repo.git"), ProviderKind::GitHub ); } #[test] fn test_azure_devops_https() { assert_eq!( detect_provider_from_url("https://dev.azure.com/org/project/_git/repo"), ProviderKind::AzureDevOps ); } #[test] fn test_azure_devops_ssh() { assert_eq!( detect_provider_from_url("git@ssh.dev.azure.com:v3/org/project/repo"), ProviderKind::AzureDevOps ); } #[test] fn test_azure_devops_legacy_visualstudio() { assert_eq!( detect_provider_from_url("https://org.visualstudio.com/project/_git/repo"), ProviderKind::AzureDevOps ); } #[test] fn test_azure_devops_git_path() { // Any URL with /_git/ is Azure DevOps assert_eq!( detect_provider_from_url("https://custom.domain.com/org/project/_git/repo"), ProviderKind::AzureDevOps ); } #[test] fn test_unknown_provider() { assert_eq!( detect_provider_from_url("https://gitlab.com/owner/repo"), ProviderKind::Unknown ); assert_eq!( detect_provider_from_url("https://bitbucket.org/owner/repo"), ProviderKind::Unknown ); } #[test] fn test_pr_url_github() { assert_eq!( detect_provider_from_pr_url("https://github.com/owner/repo/pull/123"), ProviderKind::GitHub ); assert_eq!( detect_provider_from_pr_url("https://github.company.com/owner/repo/pull/456"), ProviderKind::GitHub ); } #[test] fn test_pr_url_azure() { assert_eq!( detect_provider_from_pr_url( "https://dev.azure.com/org/project/_git/repo/pullrequest/123" ), ProviderKind::AzureDevOps ); assert_eq!( detect_provider_from_pr_url( "https://org.visualstudio.com/project/_git/repo/pullrequest/456" ), ProviderKind::AzureDevOps ); } } ================================================ FILE: crates/git-host/src/github/cli.rs ================================================ //! Minimal helpers around the GitHub CLI (`gh`). //! //! This module provides low-level access to the GitHub CLI for operations //! the REST client does not cover well. use std::{ ffi::{OsStr, OsString}, io::Write, path::Path, process::Command, }; use chrono::{DateTime, Utc}; use db::models::merge::{MergeStatus, PullRequestInfo}; use serde::Deserialize; use tempfile::NamedTempFile; use thiserror::Error; use url::Url; use utils::{command_ext::NoWindowExt, shell::resolve_executable_path_blocking}; use crate::types::{ CreatePrRequest, OpenPrInfo, PrComment, PrCommentAuthor, PrReviewComment, ReviewCommentUser, }; #[derive(Debug, Clone)] pub struct GitHubRepoInfo { pub owner: String, pub repo_name: String, /// GitHub hostname (e.g., "github.com" or enterprise hostname) pub hostname: Option, } impl GitHubRepoInfo { pub fn repo_spec(&self) -> String { match &self.hostname { Some(host) => format!("{}/{}/{}", host, self.owner, self.repo_name), None => format!("{}/{}", self.owner, self.repo_name), } } } #[derive(Deserialize)] struct GhRepoViewResponse { owner: GhRepoOwner, name: String, url: String, } #[derive(Deserialize)] struct GhRepoOwner { login: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GhCommentResponse { id: String, author: Option, #[serde(default)] author_association: String, #[serde(default)] body: String, created_at: Option>, #[serde(default)] url: String, } #[derive(Deserialize)] struct GhCommentsWrapper { comments: Vec, } #[derive(Deserialize)] struct GhUserLogin { login: Option, } #[derive(Deserialize)] struct GhReviewCommentResponse { id: i64, user: Option, #[serde(default)] body: String, created_at: Option>, #[serde(default)] html_url: String, #[serde(default)] path: String, line: Option, side: Option, #[serde(default)] diff_hunk: String, #[serde(default)] author_association: String, } #[derive(Deserialize)] struct GhMergeCommit { oid: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GhPrResponse { number: i64, url: String, #[serde(default)] state: String, merged_at: Option>, merge_commit: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GhPrListExtendedResponse { number: i64, url: String, #[serde(default)] title: String, head_ref_name: String, base_ref_name: String, } #[derive(Debug, Error)] pub enum GhCliError { #[error("GitHub CLI (`gh`) executable not found or not runnable")] NotAvailable, #[error("GitHub CLI command failed: {0}")] CommandFailed(String), #[error("GitHub CLI authentication failed: {0}")] AuthFailed(String), #[error("GitHub CLI returned unexpected output: {0}")] UnexpectedOutput(String), } #[derive(Debug, Clone, Default)] pub struct GhCli; impl GhCli { pub fn new() -> Self { Self {} } /// Ensure the GitHub CLI binary is discoverable. fn ensure_available(&self) -> Result<(), GhCliError> { resolve_executable_path_blocking("gh").ok_or(GhCliError::NotAvailable)?; Ok(()) } fn run(&self, args: I, dir: Option<&Path>) -> Result where I: IntoIterator, S: AsRef, { self.ensure_available()?; let gh = resolve_executable_path_blocking("gh").ok_or(GhCliError::NotAvailable)?; let mut cmd = Command::new(&gh); if let Some(d) = dir { cmd.current_dir(d); } for arg in args { cmd.arg(arg); } let output = cmd .no_window() .output() .map_err(|err| GhCliError::CommandFailed(err.to_string()))?; if output.status.success() { return Ok(String::from_utf8_lossy(&output.stdout).to_string()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); // Check exit code first - gh CLI uses exit code 4 for auth failures if output.status.code() == Some(4) { return Err(GhCliError::AuthFailed(stderr)); } // Fall back to string matching for older gh versions or other auth scenarios let lower = stderr.to_ascii_lowercase(); if lower.contains("authentication failed") || lower.contains("must authenticate") || lower.contains("bad credentials") || lower.contains("unauthorized") || lower.contains("gh auth login") { return Err(GhCliError::AuthFailed(stderr)); } Err(GhCliError::CommandFailed(stderr)) } pub fn get_repo_info( &self, remote_url: &str, repo_path: &Path, ) -> Result { let raw = self.run( ["repo", "view", remote_url, "--json", "owner,name,url"], Some(repo_path), )?; Self::parse_repo_info_response(&raw) } fn parse_repo_info_response(raw: &str) -> Result { let resp: GhRepoViewResponse = serde_json::from_str(raw).map_err(|e| { GhCliError::UnexpectedOutput(format!("Failed to parse gh repo view response: {e}")) })?; let hostname = Url::parse(&resp.url) .ok() .and_then(|u| u.host_str().map(String::from)); Ok(GitHubRepoInfo { owner: resp.owner.login, repo_name: resp.name, hostname, }) } /// Run `gh pr create` and parse the response. /// /// The `repo_path` parameter specifies the working directory for the command. /// This is required for compatibility with older `gh` CLI versions (e.g., v2.4.0) /// that require running from within a git repository. pub fn create_pr( &self, request: &CreatePrRequest, repo_info: &GitHubRepoInfo, repo_path: &Path, ) -> Result { // Write body to temp file to avoid shell escaping and length issues let body = request.body.as_deref().unwrap_or(""); let mut body_file = NamedTempFile::new() .map_err(|e| GhCliError::CommandFailed(format!("Failed to create temp file: {e}")))?; body_file .write_all(body.as_bytes()) .map_err(|e| GhCliError::CommandFailed(format!("Failed to write body: {e}")))?; let repo_spec = repo_info.repo_spec(); let mut args: Vec = Vec::with_capacity(14); args.push(OsString::from("pr")); args.push(OsString::from("create")); args.push(OsString::from("--repo")); args.push(OsString::from(&repo_spec)); args.push(OsString::from("--head")); args.push(OsString::from(&request.head_branch)); args.push(OsString::from("--base")); args.push(OsString::from(&request.base_branch)); args.push(OsString::from("--title")); args.push(OsString::from(&request.title)); args.push(OsString::from("--body-file")); args.push(body_file.path().as_os_str().to_os_string()); if request.draft.unwrap_or(false) { args.push(OsString::from("--draft")); } let raw = self.run(args, Some(repo_path))?; Self::parse_pr_create_text(&raw) } /// Retrieve details for a pull request by URL. pub fn view_pr(&self, pr_url: &str) -> Result { let raw = self.run( [ "pr", "view", pr_url, "--json", "number,url,state,mergedAt,mergeCommit", ], None, )?; Self::parse_pr_view(&raw) } /// List pull requests for a branch (includes closed/merged). pub fn list_prs_for_branch( &self, repo_info: &GitHubRepoInfo, branch: &str, ) -> Result, GhCliError> { let repo_spec = repo_info.repo_spec(); let raw = self.run( [ "pr", "list", "--repo", &repo_spec, "--state", "all", "--head", branch, "--json", "number,url,state,mergedAt,mergeCommit", ], None, )?; Self::parse_pr_list(&raw) } pub fn list_open_prs(&self, owner: &str, repo: &str) -> Result, GhCliError> { let raw = self.run( [ "pr", "list", "--repo", &format!("{owner}/{repo}"), "--state", "open", "--json", "number,url,title,headRefName,baseRefName", ], None, )?; Self::parse_open_pr_list(&raw) } /// Fetch comments for a pull request. pub fn get_pr_comments( &self, repo_info: &GitHubRepoInfo, pr_number: i64, ) -> Result, GhCliError> { let repo_spec = repo_info.repo_spec(); let raw = self.run( [ "pr", "view", &pr_number.to_string(), "--repo", &repo_spec, "--json", "comments", ], None, )?; Self::parse_pr_comments(&raw) } /// Fetch inline review comments for a pull request via API. pub fn get_pr_review_comments( &self, repo_info: &GitHubRepoInfo, pr_number: i64, ) -> Result, GhCliError> { let mut args = vec![ "api".to_string(), format!( "repos/{}/{}/pulls/{}/comments", repo_info.owner, repo_info.repo_name, pr_number ), ]; if let Some(ref host) = repo_info.hostname { args.push("--hostname".to_string()); args.push(host.clone()); } let raw = self.run(args, None)?; Self::parse_pr_review_comments(&raw) } pub fn pr_checkout( &self, repo_path: &Path, owner: &str, repo: &str, pr_number: i64, ) -> Result<(), GhCliError> { self.run( [ "pr", "checkout", &pr_number.to_string(), "--repo", &format!("{owner}/{repo}"), "--force", ], Some(repo_path), )?; Ok(()) } } impl GhCli { fn parse_pr_create_text(raw: &str) -> Result { let pr_url = raw .lines() .rev() .flat_map(|line| line.split_whitespace()) .map(|token| token.trim_matches(|c: char| c == '<' || c == '>')) .find(|token| token.starts_with("http") && token.contains("/pull/")) .ok_or_else(|| { GhCliError::UnexpectedOutput(format!( "gh pr create did not return a pull request URL; raw output: {raw}" )) })? .trim_end_matches(['.', ',', ';']) .to_string(); let number = pr_url .rsplit('/') .next() .ok_or_else(|| { GhCliError::UnexpectedOutput(format!( "Failed to extract PR number from URL '{pr_url}'" )) })? .trim_end_matches(|c: char| !c.is_ascii_digit()) .parse::() .map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse PR number from URL '{pr_url}': {err}" )) })?; Ok(PullRequestInfo { number, url: pr_url, status: MergeStatus::Open, merged_at: None, merge_commit_sha: None, }) } fn parse_pr_view(raw: &str) -> Result { let pr: GhPrResponse = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr view response: {err}; raw: {raw}" )) })?; Ok(Self::pr_response_to_info(pr)) } fn parse_pr_list(raw: &str) -> Result, GhCliError> { let prs: Vec = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr list response: {err}; raw: {raw}" )) })?; Ok(prs.into_iter().map(Self::pr_response_to_info).collect()) } fn parse_open_pr_list(raw: &str) -> Result, GhCliError> { let prs: Vec = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr list response: {err}; raw: {raw}" )) })?; Ok(prs .into_iter() .map(|pr| OpenPrInfo { number: pr.number, url: pr.url, title: pr.title, head_branch: pr.head_ref_name, base_branch: pr.base_ref_name, }) .collect()) } fn pr_response_to_info(pr: GhPrResponse) -> PullRequestInfo { let state = if pr.state.is_empty() { "OPEN" } else { &pr.state }; PullRequestInfo { number: pr.number, url: pr.url, status: match state.to_ascii_uppercase().as_str() { "OPEN" => MergeStatus::Open, "MERGED" => MergeStatus::Merged, "CLOSED" => MergeStatus::Closed, _ => MergeStatus::Unknown, }, merged_at: pr.merged_at, merge_commit_sha: pr.merge_commit.and_then(|c| c.oid), } } fn parse_pr_comments(raw: &str) -> Result, GhCliError> { let wrapper: GhCommentsWrapper = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse gh pr view --json comments response: {err}; raw: {raw}" )) })?; Ok(wrapper .comments .into_iter() .map(|c| PrComment { id: c.id, author: PrCommentAuthor { login: c .author .and_then(|a| a.login) .unwrap_or_else(|| "unknown".to_string()), }, author_association: c.author_association, body: c.body, created_at: c.created_at.unwrap_or_else(Utc::now), url: c.url, }) .collect()) } fn parse_pr_review_comments(raw: &str) -> Result, GhCliError> { let items: Vec = serde_json::from_str(raw.trim()).map_err(|err| { GhCliError::UnexpectedOutput(format!( "Failed to parse review comments API response: {err}; raw: {raw}" )) })?; Ok(items .into_iter() .map(|c| PrReviewComment { id: c.id, user: ReviewCommentUser { login: c .user .and_then(|u| u.login) .unwrap_or_else(|| "unknown".to_string()), }, body: c.body, created_at: c.created_at.unwrap_or_else(Utc::now), html_url: c.html_url, path: c.path, line: c.line, side: c.side, diff_hunk: c.diff_hunk, author_association: c.author_association, }) .collect()) } } ================================================ FILE: crates/git-host/src/github/mod.rs ================================================ //! GitHub hosting service implementation. mod cli; use std::{path::Path, time::Duration}; use async_trait::async_trait; use backon::{ExponentialBuilder, Retryable}; pub use cli::GhCli; use cli::{GhCliError, GitHubRepoInfo}; use db::models::merge::PullRequestInfo; use tokio::task; use tracing::info; use crate::{ GitHostProvider, types::{ CreatePrRequest, GitHostError, OpenPrInfo, PrComment, PrReviewComment, ProviderKind, UnifiedPrComment, }, }; #[derive(Debug, Clone)] pub struct GitHubProvider { gh_cli: GhCli, } impl GitHubProvider { pub fn new() -> Result { Ok(Self { gh_cli: GhCli::new(), }) } async fn get_repo_info( &self, remote_url: &str, repo_path: &Path, ) -> Result { let cli = self.gh_cli.clone(); let url = remote_url.to_string(); let path = repo_path.to_path_buf(); task::spawn_blocking(move || cli.get_repo_info(&url, &path)) .await .map_err(|err| { GitHostError::Repository(format!("Failed to get repo info from URL: {err}")) })? .map_err(Into::into) } async fn fetch_general_comments( &self, cli: &GhCli, repo_info: &GitHubRepoInfo, pr_number: i64, ) -> Result, GitHostError> { let cli = cli.clone(); let repo_info = repo_info.clone(); (|| async { let cli = cli.clone(); let repo_info = repo_info.clone(); let comments = task::spawn_blocking(move || cli.get_pr_comments(&repo_info, pr_number)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for fetching PR comments: {err}" )) })?; comments.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn fetch_review_comments( &self, cli: &GhCli, repo_info: &GitHubRepoInfo, pr_number: i64, ) -> Result, GitHostError> { let cli = cli.clone(); let repo_info = repo_info.clone(); (|| async { let cli = cli.clone(); let repo_info = repo_info.clone(); let comments = task::spawn_blocking(move || cli.get_pr_review_comments(&repo_info, pr_number)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for fetching review comments: {err}" )) })?; comments.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } } impl From for GitHostError { fn from(error: GhCliError) -> Self { match &error { GhCliError::AuthFailed(msg) => GitHostError::AuthFailed(msg.clone()), GhCliError::NotAvailable => GitHostError::CliNotInstalled { provider: ProviderKind::GitHub, }, GhCliError::CommandFailed(msg) => { let lower = msg.to_ascii_lowercase(); if lower.contains("403") || lower.contains("forbidden") { GitHostError::InsufficientPermissions(msg.clone()) } else if lower.contains("404") || lower.contains("not found") { GitHostError::RepoNotFoundOrNoAccess(msg.clone()) } else if lower.contains("not a git repository") { GitHostError::NotAGitRepository(msg.clone()) } else { GitHostError::PullRequest(msg.clone()) } } GhCliError::UnexpectedOutput(msg) => GitHostError::UnexpectedOutput(msg.clone()), } } } #[async_trait] impl GitHostProvider for GitHubProvider { async fn create_pr( &self, repo_path: &Path, remote_url: &str, request: &CreatePrRequest, ) -> Result { // Get owner/repo from the remote URL (target repo for the PR). let target_repo_info = self.get_repo_info(remote_url, repo_path).await?; // For cross-fork PRs, get the head repo info to format head_branch as "owner:branch". let head_branch = if let Some(head_url) = &request.head_repo_url { let head_repo_info = self.get_repo_info(head_url, repo_path).await?; if head_repo_info.owner != target_repo_info.owner { format!("{}:{}", head_repo_info.owner, request.head_branch) } else { request.head_branch.clone() } } else { request.head_branch.clone() }; let mut request_clone = request.clone(); request_clone.head_branch = head_branch; (|| async { let cli = self.gh_cli.clone(); let request = request_clone.clone(); let target_repo = target_repo_info.clone(); let repo_path = repo_path.to_path_buf(); let cli_result = task::spawn_blocking(move || cli.create_pr(&request, &target_repo, &repo_path)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for PR creation: {err}" )) })? .map_err(GitHostError::from)?; info!( "Created GitHub PR #{} for branch {}", cli_result.number, request_clone.head_branch ); Ok(cli_result) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn get_pr_status(&self, pr_url: &str) -> Result { let cli = self.gh_cli.clone(); let url = pr_url.to_string(); (|| async { let cli = cli.clone(); let url = url.clone(); let pr = task::spawn_blocking(move || cli.view_pr(&url)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for viewing PR: {err}" )) })?; pr.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|err: &GitHostError| err.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn list_prs_for_branch( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result, GitHostError> { let repo_info = self.get_repo_info(remote_url, repo_path).await?; let cli = self.gh_cli.clone(); let branch = branch_name.to_string(); (|| async { let cli = cli.clone(); let repo_info = repo_info.clone(); let branch = branch.clone(); let prs = task::spawn_blocking(move || cli.list_prs_for_branch(&repo_info, &branch)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for listing PRs: {err}" )) })?; prs.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } async fn get_pr_comments( &self, repo_path: &Path, remote_url: &str, pr_number: i64, ) -> Result, GitHostError> { let repo_info = self.get_repo_info(remote_url, repo_path).await?; // Fetch both types of comments in parallel let cli1 = self.gh_cli.clone(); let cli2 = self.gh_cli.clone(); let (general_result, review_result) = tokio::join!( self.fetch_general_comments(&cli1, &repo_info, pr_number), self.fetch_review_comments(&cli2, &repo_info, pr_number) ); let general_comments = general_result?; let review_comments = review_result?; // Convert and merge into unified timeline let mut unified: Vec = Vec::new(); for c in general_comments { unified.push(UnifiedPrComment::General { id: c.id, author: c.author.login, author_association: Some(c.author_association), body: c.body, created_at: c.created_at, url: Some(c.url), }); } for c in review_comments { unified.push(UnifiedPrComment::Review { id: c.id, author: c.user.login, author_association: Some(c.author_association), body: c.body, created_at: c.created_at, url: Some(c.html_url), path: c.path, line: c.line, side: c.side, diff_hunk: Some(c.diff_hunk), }); } // Sort by creation time unified.sort_by_key(|c| c.created_at()); Ok(unified) } async fn list_open_prs( &self, repo_path: &Path, remote_url: &str, ) -> Result, GitHostError> { let repo_info = self.get_repo_info(remote_url, repo_path).await?; let cli = self.gh_cli.clone(); (|| async { let cli = cli.clone(); let owner = repo_info.owner.clone(); let repo_name = repo_info.repo_name.clone(); let prs = task::spawn_blocking(move || cli.list_open_prs(&owner, &repo_name)) .await .map_err(|err| { GitHostError::PullRequest(format!( "Failed to execute GitHub CLI for listing open PRs: {err}" )) })?; prs.map_err(GitHostError::from) }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) .with_max_delay(Duration::from_secs(30)) .with_max_times(3) .with_jitter(), ) .when(|e: &GitHostError| e.should_retry()) .notify(|err: &GitHostError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), err ); }) .await } fn provider_kind(&self) -> ProviderKind { ProviderKind::GitHub } } ================================================ FILE: crates/git-host/src/lib.rs ================================================ mod detection; mod types; pub mod azure; pub mod github; use std::path::Path; use async_trait::async_trait; use db::models::merge::PullRequestInfo; use detection::detect_provider_from_url; use enum_dispatch::enum_dispatch; pub use types::{ CreatePrRequest, GitHostError, OpenPrInfo, PrComment, PrCommentAuthor, PrReviewComment, ProviderKind, ReviewCommentUser, UnifiedPrComment, }; use self::{azure::AzureDevOpsProvider, github::GitHubProvider}; #[async_trait] #[enum_dispatch(GitHostService)] pub trait GitHostProvider: Send + Sync { async fn create_pr( &self, repo_path: &Path, remote_url: &str, request: &CreatePrRequest, ) -> Result; async fn get_pr_status(&self, pr_url: &str) -> Result; async fn list_prs_for_branch( &self, repo_path: &Path, remote_url: &str, branch_name: &str, ) -> Result, GitHostError>; async fn get_pr_comments( &self, repo_path: &Path, remote_url: &str, pr_number: i64, ) -> Result, GitHostError>; async fn list_open_prs( &self, repo_path: &Path, remote_url: &str, ) -> Result, GitHostError>; fn provider_kind(&self) -> ProviderKind; } #[enum_dispatch] pub enum GitHostService { GitHub(GitHubProvider), AzureDevOps(AzureDevOpsProvider), } impl GitHostService { pub fn from_url(url: &str) -> Result { match detect_provider_from_url(url) { ProviderKind::GitHub => Ok(Self::GitHub(GitHubProvider::new()?)), ProviderKind::AzureDevOps => Ok(Self::AzureDevOps(AzureDevOpsProvider::new()?)), ProviderKind::Unknown => Err(GitHostError::UnsupportedProvider), } } } ================================================ FILE: crates/git-host/src/types.rs ================================================ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; use ts_rs::TS; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] pub enum ProviderKind { GitHub, AzureDevOps, Unknown, } impl std::fmt::Display for ProviderKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ProviderKind::GitHub => write!(f, "GitHub"), ProviderKind::AzureDevOps => write!(f, "Azure DevOps"), ProviderKind::Unknown => write!(f, "Unknown"), } } } #[derive(Debug, Clone)] pub struct CreatePrRequest { pub title: String, pub body: Option, pub head_branch: String, pub base_branch: String, pub draft: Option, /// URL of the repo containing the head branch (for cross-fork PRs). pub head_repo_url: Option, } #[derive(Debug, Error)] pub enum GitHostError { #[error("Repository error: {0}")] Repository(String), #[error("Pull request error: {0}")] PullRequest(String), #[error("Authentication failed: {0}")] AuthFailed(String), #[error("Insufficient permissions: {0}")] InsufficientPermissions(String), #[error("Repository not found or no access: {0}")] RepoNotFoundOrNoAccess(String), #[error("{provider} CLI is not installed or not available in PATH")] CliNotInstalled { provider: ProviderKind }, #[error("Not a git repository: {0}")] NotAGitRepository(String), #[error("Unsupported git hosting provider")] UnsupportedProvider, #[error("CLI returned unexpected output: {0}")] UnexpectedOutput(String), } impl GitHostError { pub fn should_retry(&self) -> bool { !matches!( self, GitHostError::AuthFailed(_) | GitHostError::InsufficientPermissions(_) | GitHostError::RepoNotFoundOrNoAccess(_) | GitHostError::CliNotInstalled { .. } | GitHostError::NotAGitRepository(_) | GitHostError::UnsupportedProvider ) } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PrCommentAuthor { pub login: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct PrComment { pub id: String, pub author: PrCommentAuthor, pub author_association: String, pub body: String, pub created_at: DateTime, pub url: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ReviewCommentUser { pub login: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PrReviewComment { pub id: i64, pub user: ReviewCommentUser, pub body: String, pub created_at: DateTime, pub html_url: String, pub path: String, pub line: Option, pub side: Option, pub diff_hunk: String, pub author_association: String, } #[derive(Debug, Clone, Serialize, TS)] #[serde(tag = "comment_type", rename_all = "snake_case")] #[ts(tag = "comment_type", rename_all = "snake_case")] pub enum UnifiedPrComment { General { id: String, author: String, author_association: Option, body: String, created_at: DateTime, url: Option, }, Review { id: i64, author: String, author_association: Option, body: String, created_at: DateTime, url: Option, path: String, line: Option, side: Option, diff_hunk: Option, }, } impl UnifiedPrComment { pub fn created_at(&self) -> DateTime { match self { UnifiedPrComment::General { created_at, .. } => *created_at, UnifiedPrComment::Review { created_at, .. } => *created_at, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct OpenPrInfo { pub number: i64, pub url: String, pub title: String, pub head_branch: String, pub base_branch: String, } ================================================ FILE: crates/local-deployment/Cargo.toml ================================================ [package] name = "local-deployment" version = "0.1.33" edition = "2024" [dependencies] api-types = { path = "../api-types" } db = { path = "../db" } executors = { path="../executors" } deployment = { path = "../deployment" } relay-control = { path = "../relay-control" } server-info = { path = "../server-info" } services = { path = "../services" } worktree-manager = { path = "../worktree-manager" } workspace-manager = { path = "../workspace-manager" } utils = { path = "../utils" } git = { path = "../git" } trusted-key-auth = { path = "../trusted-key-auth" } tokio-util = { version = "0.7", features = ["io"] } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } async-trait = { workspace = true } thiserror = { workspace = true } command-group = { version = "5.0", features = ["with-tokio"] } futures = "0.3" tokio = { workspace = true } globwalk = "0.9" portable-pty = "0.8" [build-dependencies] dotenv = "0.15" [dev-dependencies] tempfile = "3.8" ================================================ FILE: crates/local-deployment/build.rs ================================================ use std::path::Path; fn main() { // Load .env from the workspace root let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let env_file = workspace_root.join(".env"); dotenv::from_path(&env_file).ok(); // Recompile when VK_SHARED_API_BASE changes, since it's read via option_env!() println!("cargo:rerun-if-env-changed=VK_SHARED_API_BASE"); if env_file.exists() { println!("cargo:rerun-if-changed={}", env_file.display()); } // Pass VK_SHARED_API_BASE to the compiler so option_env!() sees it if let Ok(val) = std::env::var("VK_SHARED_API_BASE") { println!("cargo:rustc-env=VK_SHARED_API_BASE={}", val); } } ================================================ FILE: crates/local-deployment/src/command.rs ================================================ use command_group::AsyncGroupChild; use services::services::container::ContainerError; pub async fn kill_process_group(child: &mut AsyncGroupChild) -> Result<(), ContainerError> { utils::process::kill_process_group(child) .await .map_err(ContainerError::KillFailed) } ================================================ FILE: crates/local-deployment/src/container.rs ================================================ use std::{ collections::HashMap, io, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; use anyhow::anyhow; use async_trait::async_trait; use command_group::AsyncGroupChild; use db::{ DBService, models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ ExecutionContext, ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus, }, execution_process_repo_state::ExecutionProcessRepoState, repo::Repo, scratch::{DraftFollowUpData, Scratch, ScratchType}, session::{Session, SessionError}, workspace::Workspace, workspace_repo::WorkspaceRepo, }, }; use deployment::DeploymentError; use executors::{ actions::{ Executable, ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, coding_agent_initial::CodingAgentInitialRequest, }, approvals::{ExecutorApprovalService, NoopExecutorApprovalService}, env::{ExecutionEnv, RepoContext}, executors::{BaseCodingAgent, CancellationToken, ExecutorExitResult, ExecutorExitSignal}, logs::{NormalizedEntryType, utils::patch::extract_normalized_entry_from_patch}, }; use futures::{FutureExt, TryStreamExt, stream::select}; use git::GitService; use serde_json::json; use services::services::{ analytics::AnalyticsContext, approvals::{Approvals, executor_approvals::ExecutorApprovalBridge}, config::{Config, DEFAULT_COMMIT_REMINDER_PROMPT}, container::{ContainerError, ContainerRef, ContainerService}, diff_stream::{self, DiffStreamHandle}, file::FileService, notification::NotificationService, queued_message::QueuedMessageService, remote_client::RemoteClient, remote_sync, }; use tokio::{sync::RwLock, task::JoinHandle}; use tokio_util::io::ReaderStream; use utils::{ log_msg::LogMsg, msg_store::MsgStore, text::{git_branch_id, short_uuid, truncate_to_char_boundary}, }; use uuid::Uuid; use workspace_manager::{RepoWorkspaceInput, WorkspaceError, WorkspaceManager}; use crate::{command, copy}; const WORKSPACE_TOUCH_DEBOUNCE: Duration = Duration::from_mins(2); #[derive(Clone)] pub struct LocalContainerService { db: DBService, workspace_manager: WorkspaceManager, child_store: Arc>>>>, cancellation_tokens: Arc>>, msg_stores: Arc>>>, /// Tracks background tasks that stream logs to the database. /// When stopping execution, we await these to ensure logs are fully persisted. db_stream_handles: Arc>>>, exit_monitor_handles: Arc>>>, workspace_touch_times: Arc>>, config: Arc>, git: GitService, file_service: FileService, analytics: Option, approvals: Approvals, queued_message_service: QueuedMessageService, notification_service: NotificationService, remote_client: Option, } impl LocalContainerService { #[allow(clippy::too_many_arguments)] pub async fn new( db: DBService, workspace_manager: WorkspaceManager, msg_stores: Arc>>>, config: Arc>, git: GitService, file_service: FileService, analytics: Option, approvals: Approvals, queued_message_service: QueuedMessageService, remote_client: Option, ) -> Self { let child_store = Arc::new(RwLock::new(HashMap::new())); let cancellation_tokens = Arc::new(RwLock::new(HashMap::new())); let db_stream_handles = Arc::new(RwLock::new(HashMap::new())); let exit_monitor_handles = Arc::new(RwLock::new(HashMap::new())); let workspace_touch_times = Arc::new(RwLock::new(HashMap::new())); let notification_service = NotificationService::new(config.clone()); let container = LocalContainerService { db, workspace_manager, child_store, cancellation_tokens, msg_stores, db_stream_handles, exit_monitor_handles, workspace_touch_times, config, git, file_service, analytics, approvals, queued_message_service, notification_service, remote_client, }; container.spawn_workspace_cleanup(); container } fn map_workspace_manager_error(err: WorkspaceError) -> ContainerError { match err { WorkspaceError::Database(err) => ContainerError::Sqlx(err), WorkspaceError::Worktree(err) => ContainerError::Worktree(err), WorkspaceError::GitService(err) => ContainerError::GitServiceError(err), WorkspaceError::Io(err) => ContainerError::Io(err), WorkspaceError::NoRepositories => { ContainerError::Other(anyhow!("No repositories provided")) } WorkspaceError::Repo(err) => ContainerError::Other(anyhow!(err)), WorkspaceError::WorkspaceNotFound => { ContainerError::Other(anyhow!("Workspace not found")) } WorkspaceError::RepoAlreadyAttached => { ContainerError::Other(anyhow!("Repository already attached to workspace")) } WorkspaceError::BranchNotFound { repo_name, branch } => ContainerError::Other(anyhow!( "Branch '{}' does not exist in repository '{}'", branch, repo_name )), WorkspaceError::PartialCreation(msg) => ContainerError::Other(anyhow!(msg)), } } async fn workspace_repo_inputs( &self, workspace_id: Uuid, ) -> Result<(Vec, Vec), ContainerError> { let workspace_repos = WorkspaceRepo::find_by_workspace_id(&self.db.pool, workspace_id).await?; if workspace_repos.is_empty() { return Err(ContainerError::Other(anyhow!( "Workspace has no repositories configured" ))); } let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace_id).await?; let target_branches: HashMap<_, _> = workspace_repos .iter() .map(|wr| (wr.repo_id, wr.target_branch.clone())) .collect(); let workspace_inputs: Vec = repositories .iter() .map(|repo| { let target_branch = target_branches.get(&repo.id).cloned().ok_or_else(|| { ContainerError::Other(anyhow!( "Missing target branch mapping for repo {} in workspace {}", repo.id, workspace_id )) })?; Ok(RepoWorkspaceInput::new(repo.clone(), target_branch)) }) .collect::>()?; Ok((repositories, workspace_inputs)) } pub async fn get_child_from_store(&self, id: &Uuid) -> Option>> { let map = self.child_store.read().await; map.get(id).cloned() } pub async fn add_child_to_store(&self, id: Uuid, exec: AsyncGroupChild) { let mut map = self.child_store.write().await; map.insert(id, Arc::new(RwLock::new(exec))); } pub async fn remove_child_from_store(&self, id: &Uuid) { let mut map = self.child_store.write().await; map.remove(id); } async fn add_cancellation_token(&self, id: Uuid, token: CancellationToken) { let mut map = self.cancellation_tokens.write().await; map.insert(id, token); } async fn take_cancellation_token(&self, id: &Uuid) -> Option { let mut map = self.cancellation_tokens.write().await; map.remove(id) } async fn add_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>) { let mut map = self.db_stream_handles.write().await; map.insert(id, handle); } async fn take_db_stream_handle(&self, id: &Uuid) -> Option> { let mut map = self.db_stream_handles.write().await; map.remove(id) } async fn add_exit_monitor_handle(&self, id: Uuid, handle: JoinHandle<()>) { let mut map = self.exit_monitor_handles.write().await; map.insert(id, handle); } async fn take_exit_monitor_handle(&self, id: &Uuid) -> Option> { let mut map = self.exit_monitor_handles.write().await; map.remove(id) } pub async fn cleanup_workspace(&self, workspace: &Workspace) { let Some(container_ref) = &workspace.container_ref else { return; }; let workspace_dir = PathBuf::from(container_ref); let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id) .await .unwrap_or_default(); if repositories.is_empty() { tracing::warn!( "No repositories found for workspace {}, cleaning up workspace directory only", workspace.id ); if workspace_dir.exists() && let Err(e) = tokio::fs::remove_dir_all(&workspace_dir).await { tracing::warn!("Failed to remove workspace directory: {}", e); } } else { WorkspaceManager::cleanup_workspace(&workspace_dir, &repositories) .await .unwrap_or_else(|e| { tracing::warn!( "Failed to clean up workspace for workspace {}: {}", workspace.id, e ); }); } let _ = Workspace::mark_worktree_deleted(&self.db.pool, workspace.id).await; } pub async fn cleanup_expired_workspaces(&self) -> Result<(), DeploymentError> { if std::env::var("DISABLE_WORKTREE_CLEANUP").is_ok() { tracing::info!( "Expired workspace cleanup is disabled via DISABLE_WORKTREE_CLEANUP environment variable" ); return Ok(()); } let expired_workspaces = Workspace::find_expired_for_cleanup(&self.db.pool).await?; if expired_workspaces.is_empty() { tracing::debug!("No expired workspaces found"); return Ok(()); } tracing::info!( "Found {} expired workspaces to clean up", expired_workspaces.len() ); for workspace in &expired_workspaces { self.cleanup_workspace(workspace).await; } Ok(()) } pub fn spawn_workspace_cleanup(&self) { let container = self.clone(); tokio::spawn(async move { container .workspace_manager .cleanup_orphan_workspaces() .await; let mut cleanup_interval = tokio::time::interval(tokio::time::Duration::from_secs(1800)); // 30 minutes loop { cleanup_interval.tick().await; tracing::info!("Starting periodic workspace cleanup..."); container .cleanup_expired_workspaces() .await .unwrap_or_else(|e| { tracing::error!("Failed to clean up expired workspaces: {}", e) }); } }); } /// Record the current HEAD commit for each repository as the "after" state. /// Errors are silently ignored since this runs after the main execution completes /// and failure should not block process finalization. async fn update_after_head_commits(&self, exec_id: Uuid) { if let Ok(ctx) = ExecutionProcess::load_context(&self.db.pool, exec_id).await { let workspace_root = self.workspace_to_current_dir(&ctx.workspace); for repo in &ctx.repos { let repo_path = workspace_root.join(&repo.name); if let Ok(head) = self.git().get_head_info(&repo_path) { let _ = ExecutionProcessRepoState::update_after_head_commit( &self.db.pool, exec_id, repo.id, &head.oid, ) .await; } } } } /// Get the commit message based on the execution run reason. async fn get_commit_message(&self, ctx: &ExecutionContext) -> String { match ctx.execution_process.run_reason { ExecutionProcessRunReason::CodingAgent => { // Try to retrieve the task summary from the coding agent turn // otherwise fallback to default message match CodingAgentTurn::find_by_execution_process_id( &self.db().pool, ctx.execution_process.id, ) .await { Ok(Some(turn)) if turn.summary.is_some() => turn.summary.unwrap(), Ok(_) => { tracing::debug!( "No summary found for execution process {}, using default message", ctx.execution_process.id ); format!( "Commit changes from coding agent for workspace {}", ctx.workspace.id ) } Err(e) => { tracing::debug!( "Failed to retrieve summary for execution process {}: {}", ctx.execution_process.id, e ); format!( "Commit changes from coding agent for workspace {}", ctx.workspace.id ) } } } ExecutionProcessRunReason::CleanupScript => { format!("Cleanup script changes for workspace {}", ctx.workspace.id) } _ => format!( "Changes from execution process {}", ctx.execution_process.id ), } } /// Check which repos have uncommitted changes. Fails if any repo is inaccessible. fn check_repos_for_changes( &self, workspace_root: &Path, repos: &[Repo], ) -> Result, ContainerError> { let git = GitService::new(); let mut repos_with_changes = Vec::new(); for repo in repos { let worktree_path = workspace_root.join(&repo.name); match git.get_worktree_status(&worktree_path) { Ok(ws) if !ws.entries.is_empty() => { repos_with_changes.push((repo.clone(), worktree_path)); } Ok(_) => { tracing::debug!("No changes in repo '{}'", repo.name); } Err(e) => { return Err(ContainerError::Other(anyhow!( "Pre-flight check failed for repo '{}': {}", repo.name, e ))); } } } Ok(repos_with_changes) } async fn has_commits_from_execution( &self, ctx: &ExecutionContext, ) -> Result { let workspace_root = self.workspace_to_current_dir(&ctx.workspace); let repo_states = ExecutionProcessRepoState::find_by_execution_process_id( &self.db.pool, ctx.execution_process.id, ) .await?; for repo in &ctx.repos { let repo_path = workspace_root.join(&repo.name); let current_head = self.git().get_head_info(&repo_path).ok().map(|h| h.oid); let before_head = repo_states .iter() .find(|s| s.repo_id == repo.id) .and_then(|s| s.before_head_commit.clone()); if current_head != before_head { return Ok(true); } } Ok(false) } /// Commit changes to each repo. Logs failures but continues with other repos. fn commit_repos(&self, repos_with_changes: Vec<(Repo, PathBuf)>, message: &str) -> bool { let mut any_committed = false; for (repo, worktree_path) in repos_with_changes { tracing::debug!( "Committing changes for repo '{}' at {:?}", repo.name, &worktree_path ); match self.git().commit(&worktree_path, message) { Ok(true) => { any_committed = true; tracing::info!("Committed changes in repo '{}'", repo.name); } Ok(false) => { tracing::warn!("No changes committed in repo '{}' (unexpected)", repo.name); } Err(e) => { tracing::warn!("Failed to commit in repo '{}': {}", repo.name, e); } } } any_committed } /// Spawn a background task that polls the child process for completion and /// cleans up the execution entry when it exits. pub fn spawn_exit_monitor( &self, exec_id: &Uuid, exit_signal: Option, ) -> JoinHandle<()> { let exec_id = *exec_id; let child_store = self.child_store.clone(); let msg_stores = self.msg_stores.clone(); let db = self.db.clone(); let config = self.config.clone(); let container = self.clone(); let analytics = self.analytics.clone(); let mut process_exit_rx = self.spawn_os_exit_watcher(exec_id); tokio::spawn(async move { let mut exit_signal_future = exit_signal .map(|rx| rx.boxed()) // wait for result .unwrap_or_else(|| std::future::pending().boxed()); // no signal, stall forever let status_result: std::io::Result; // Wait for process to exit, or exit signal from executor tokio::select! { // Exit signal with result. // Some coding agent processes do not automatically exit after processing the user request; instead the executor // signals when processing has finished to gracefully kill the process. exit_result = &mut exit_signal_future => { // Executor signaled completion: kill group and use the provided result if let Some(child_lock) = child_store.read().await.get(&exec_id).cloned() { let mut child = child_lock.write().await ; if let Err(err) = command::kill_process_group(&mut child).await { tracing::error!("Failed to kill process group after exit signal: {} {}", exec_id, err); } } // Map the exit result to appropriate exit status status_result = match exit_result { Ok(ExecutorExitResult::Success) => Ok(success_exit_status()), Ok(ExecutorExitResult::Failure) => Ok(failure_exit_status()), Err(_) => Ok(success_exit_status()), // Channel closed, assume success }; } // Process exit exit_status_result = &mut process_exit_rx => { status_result = exit_status_result.unwrap_or_else(|e| Err(std::io::Error::other(e))); } } let (exit_code, status) = match status_result { Ok(exit_status) => { let code = exit_status.code().unwrap_or(-1) as i64; let status = if exit_status.success() { ExecutionProcessStatus::Completed } else { ExecutionProcessStatus::Failed }; (Some(code), status) } Err(_) => (None, ExecutionProcessStatus::Failed), }; if !ExecutionProcess::was_stopped(&db.pool, exec_id).await && let Err(e) = ExecutionProcess::update_completion(&db.pool, exec_id, status, exit_code).await { tracing::error!("Failed to update execution process completion: {}", e); } if let Ok(ctx) = ExecutionProcess::load_context(&db.pool, exec_id).await { // Update executor session summary if available if let Err(e) = container.update_executor_session_summary(&exec_id).await { tracing::warn!("Failed to update executor session summary: {}", e); } let success = matches!( ctx.execution_process.status, ExecutionProcessStatus::Completed ) && exit_code == Some(0); let cleanup_done = matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::CleanupScript ) && !matches!( ctx.execution_process.status, ExecutionProcessStatus::Running ); let mut already_finalized = false; if success || cleanup_done { // Commit changes (if any) and get feedback about whether changes were made let changes_committed = match container.try_commit_changes(&ctx).await { Ok(committed) => committed, Err(e) => { tracing::error!("Failed to commit changes after execution: {}", e); // Treat commit failures as if changes were made to be safe true } }; let should_start_next = if matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent ) { // Check if agent made commits OR if we just committed uncommitted changes changes_committed || container .has_commits_from_execution(&ctx) .await .unwrap_or(false) } else { true }; if should_start_next { // If the process exited successfully, start the next action if let Err(e) = container.try_start_next_action(&ctx).await { tracing::error!("Failed to start next action after completion: {}", e); } } else { tracing::info!( "Skipping cleanup script for workspace {} - no changes made by coding agent", ctx.workspace.id ); // Manually finalize task since we're bypassing normal execution flow container.finalize_task(&ctx).await; already_finalized = true; } } if !already_finalized && container.should_finalize(&ctx) { let has_chained_follow_up = ctx .execution_process .executor_action() .ok() .and_then(|action| action.next_action()) .is_some(); let mut started_queued_follow_up = false; // Only execute queued messages if the execution succeeded // If it failed or was killed, just clear the queue and finalize let should_execute_queued = !matches!( ctx.execution_process.status, ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed ); if let Some(queued_msg) = container.queued_message_service.take_queued(ctx.session.id) { if should_execute_queued { tracing::info!( "Found queued message for session {}, starting follow-up execution", ctx.session.id ); // Delete the scratch since we're consuming the queued message if let Err(e) = Scratch::delete( &db.pool, ctx.session.id, &ScratchType::DraftFollowUp, ) .await { tracing::warn!( "Failed to delete scratch after consuming queued message: {}", e ); } // Execute the queued follow-up if let Err(e) = container .start_queued_follow_up(&ctx, &queued_msg.data) .await { tracing::error!("Failed to start queued follow-up: {}", e); // Fall back to finalization if follow-up fails container.finalize_task(&ctx).await; } else { started_queued_follow_up = true; } } else { // Execution failed or was killed - discard the queued message and finalize tracing::info!( "Discarding queued message for session {} due to execution status {:?}", ctx.session.id, ctx.execution_process.status ); container.finalize_task(&ctx).await; } } else { container.finalize_task(&ctx).await; } let should_mark_turn_unseen = matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent ) && !has_chained_follow_up && !started_queued_follow_up; if should_mark_turn_unseen && let Err(e) = CodingAgentTurn::mark_unseen_by_execution_process_id( &db.pool, ctx.execution_process.id, ) .await { tracing::warn!( "Failed to mark coding agent turn unseen for execution {}: {}", ctx.execution_process.id, e ); } } // When a parallel setup script finishes and no coding agent is running, // consume any queued message that was stuck waiting if matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::SetupScript ) && !container.should_finalize(&ctx) { let has_running_agent = ExecutionProcess::has_running_coding_agent_for_session( &db.pool, ctx.session.id, ) .await .unwrap_or(true); if !has_running_agent && let Some(queued_msg) = container.queued_message_service.take_queued(ctx.session.id) { tracing::info!( "Parallel setup script finished with queued message for session {}, starting follow-up", ctx.session.id ); if let Err(e) = Scratch::delete(&db.pool, ctx.session.id, &ScratchType::DraftFollowUp) .await { tracing::warn!( "Failed to delete scratch after consuming queued message: {}", e ); } if let Err(e) = container .start_queued_follow_up(&ctx, &queued_msg.data) .await { tracing::error!( "Failed to start queued follow-up from setup script completion: {}", e ); } } } // Fire analytics event when CodingAgent execution has finished if config.read().await.analytics_enabled && matches!( &ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent ) && let Some(analytics) = &analytics { analytics.analytics_service.track_event(&analytics.user_id, "task_attempt_finished", Some(json!({ "workspace_id": ctx.workspace.id.to_string(), "session_id": ctx.session.id.to_string(), "execution_success": matches!(ctx.execution_process.status, ExecutionProcessStatus::Completed), "exit_code": ctx.execution_process.exit_code, }))); } // Sync workspace to remote after CodingAgent execution if matches!( &ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent ) && let Some(client) = &container.remote_client { let stats = diff_stream::compute_diff_stats( &container.db.pool, &container.git, &ctx.workspace, ) .await; let workspace_name = Workspace::find_by_id_with_status(&container.db.pool, ctx.workspace.id) .await .ok() .flatten() .and_then(|ws| ws.workspace.name); let client = client.clone(); let workspace_id = ctx.workspace.id; let archived = ctx.workspace.archived; tokio::spawn(async move { remote_sync::sync_workspace_to_remote( &client, workspace_id, workspace_name.map(Some), Some(archived), stats.as_ref(), ) .await; }); } } // Now that commit/next-action/finalization steps for this process are complete, // capture the HEAD OID as the definitive "after" state (best-effort). container.update_after_head_commits(exec_id).await; // Wait for DB persistence to complete before cleaning up MsgStore let db_stream_handle = container.take_db_stream_handle(&exec_id).await; if let Some(msg_arc) = msg_stores.write().await.remove(&exec_id) { msg_arc.push_finished(); } if let Some(handle) = db_stream_handle { let _ = tokio::time::timeout(Duration::from_secs(5), handle).await; } // Cleanup child handle child_store.write().await.remove(&exec_id); }) } pub fn spawn_os_exit_watcher( &self, exec_id: Uuid, ) -> tokio::sync::oneshot::Receiver> { let (tx, rx) = tokio::sync::oneshot::channel::>(); let child_store = self.child_store.clone(); tokio::spawn(async move { loop { let child_lock = { let map = child_store.read().await; map.get(&exec_id).cloned() }; if let Some(child_lock) = child_lock { let mut child_handler = child_lock.write().await; match child_handler.try_wait() { Ok(Some(status)) => { let _ = tx.send(Ok(status)); break; } Ok(None) => {} Err(e) => { let _ = tx.send(Err(e)); break; } } } else { let _ = tx.send(Err(io::Error::other(format!( "Child handle missing for {exec_id}" )))); break; } tokio::time::sleep(Duration::from_millis(250)).await; } }); rx } pub fn dir_name_from_workspace(workspace_id: &Uuid, task_title: &str) -> String { let task_title_id = git_branch_id(task_title); format!("{}-{}", short_uuid(workspace_id), task_title_id) } async fn track_child_msgs_in_store(&self, id: Uuid, child: &mut AsyncGroupChild) { let store = Arc::new(MsgStore::new()); let out = child.inner().stdout.take().expect("no stdout"); let err = child.inner().stderr.take().expect("no stderr"); // Map stdout bytes -> LogMsg::Stdout let out = ReaderStream::new(out) .map_ok(|chunk| LogMsg::Stdout(String::from_utf8_lossy(&chunk).into_owned())); // Map stderr bytes -> LogMsg::Stderr let err = ReaderStream::new(err) .map_ok(|chunk| LogMsg::Stderr(String::from_utf8_lossy(&chunk).into_owned())); // If you have a JSON Patch source, map it to LogMsg::JsonPatch too, then select all three. // Merge and forward into the store let merged = select(out, err); // Stream> store.clone().spawn_forwarder(merged); let mut map = self.msg_stores().write().await; map.insert(id, store); } /// Create a live diff log stream for ongoing attempts for WebSocket /// Returns a stream that owns the filesystem watcher - when dropped, watcher is cleaned up async fn create_live_diff_stream( &self, args: diff_stream::DiffStreamArgs, ) -> Result { diff_stream::create(args) .await .map_err(|e| ContainerError::Other(anyhow!("{e}"))) } /// Extract the last assistant message from the MsgStore history fn extract_last_assistant_message(&self, exec_id: &Uuid) -> Option { // Get the MsgStore for this execution let msg_stores = self.msg_stores.try_read().ok()?; let msg_store = msg_stores.get(exec_id)?; // Get the history and scan in reverse for the last assistant message let history = msg_store.get_history(); for msg in history.iter().rev() { if let LogMsg::JsonPatch(patch) = msg { // Try to extract a NormalizedEntry from the patch if let Some((_, entry)) = extract_normalized_entry_from_patch(patch) && matches!(entry.entry_type, NormalizedEntryType::AssistantMessage) { let content = entry.content.trim(); if !content.is_empty() { const MAX_SUMMARY_LENGTH: usize = 4096; if content.len() > MAX_SUMMARY_LENGTH { let truncated = truncate_to_char_boundary(content, MAX_SUMMARY_LENGTH); return Some(format!("{truncated}...")); } return Some(content.to_string()); } } } } None } /// Update the coding agent turn summary with the final assistant message async fn update_executor_session_summary(&self, exec_id: &Uuid) -> Result<(), anyhow::Error> { // Check if there's a coding agent turn for this execution process let turn = CodingAgentTurn::find_by_execution_process_id(&self.db.pool, *exec_id).await?; if let Some(turn) = turn { // Only update if summary is not already set if turn.summary.is_none() { if let Some(summary) = self.extract_last_assistant_message(exec_id) { CodingAgentTurn::update_summary(&self.db.pool, *exec_id, &summary).await?; } else { tracing::debug!("No assistant message found for execution {}", exec_id); } } } Ok(()) } /// Copy project files and workspace attachments to the workspace. /// Skips files that already exist (fast no-op if all exist). async fn copy_files_and_images( &self, workspace_dir: &Path, workspace: &Workspace, ) -> Result<(), ContainerError> { let repos = WorkspaceRepo::find_repos_with_copy_files(&self.db.pool, workspace.id).await?; for repo in &repos { if let Some(copy_files) = &repo.copy_files && !copy_files.trim().is_empty() { let worktree_path = workspace_dir.join(&repo.name); self.copy_project_files(&repo.path, &worktree_path, copy_files) .await .unwrap_or_else(|e| { tracing::warn!( "Failed to copy project files for repo '{}': {}", repo.name, e ); }); } } let agent_working_dir = Session::find_latest_by_workspace_id(&self.db.pool, workspace.id) .await? .and_then(|session| session.agent_working_dir); if let Err(e) = self .file_service .copy_files_by_workspace_to_worktree( workspace_dir, workspace.id, agent_working_dir.as_deref(), ) .await { tracing::warn!("Failed to copy workspace files to workspace: {}", e); } Ok(()) } /// Create workspace-level CLAUDE.md and AGENTS.md files that import from each repo. /// Uses the @import syntax to reference each repo's config files. /// Skips creating files if they already exist or if no repos have the source file. async fn create_workspace_config_files( workspace_dir: &Path, repos: &[Repo], ) -> Result<(), ContainerError> { const CONFIG_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"]; for config_file in CONFIG_FILES { let workspace_config_path = workspace_dir.join(config_file); if workspace_config_path.exists() { tracing::trace!( "Workspace config file {} already exists, skipping", config_file ); continue; } let mut import_lines = Vec::new(); for repo in repos { let repo_config_path = workspace_dir.join(&repo.name).join(config_file); if repo_config_path.exists() { import_lines.push(format!("@{}/{}", repo.name, config_file)); } } if import_lines.is_empty() { tracing::trace!( "No repos have {}, skipping workspace config creation", config_file ); continue; } let content = import_lines.join("\n") + "\n"; if let Err(e) = tokio::fs::write(&workspace_config_path, &content).await { tracing::warn!( "Failed to create workspace config file {}: {}", config_file, e ); continue; } tracing::info!( "Created workspace {} with {} import(s)", config_file, import_lines.len() ); } Ok(()) } /// Start a follow-up execution from a queued message async fn start_queued_follow_up( &self, ctx: &ExecutionContext, queued_data: &DraftFollowUpData, ) -> Result { let executor_profile_id = queued_data.executor_config.profile_id(); // Validate executor matches session if session has prior executions let expected_executor: Option = ExecutionProcess::latest_executor_profile_for_session(&self.db.pool, ctx.session.id) .await? .map(|profile| profile.executor.to_string()) .or_else(|| ctx.session.executor.clone()); if let Some(expected) = expected_executor { let actual = executor_profile_id.executor.to_string(); if expected != actual { return Err(SessionError::ExecutorMismatch { expected, actual }.into()); } } if ctx.session.executor.is_none() { Session::update_executor( &self.db.pool, ctx.session.id, &executor_profile_id.executor.to_string(), ) .await?; } // Get latest agent turn for session continuity (from coding agent turns) let latest_session_info = CodingAgentTurn::find_latest_session_info(&self.db.pool, ctx.session.id).await?; let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, ctx.workspace.id).await?; let cleanup_action = self.cleanup_actions_for_repos(&repos); let working_dir = ctx .session .agent_working_dir .as_ref() .filter(|dir| !dir.is_empty()) .cloned(); let action_type = if let Some(info) = latest_session_info { ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { prompt: queued_data.message.clone(), session_id: info.session_id, reset_to_message_id: None, executor_config: queued_data.executor_config.clone(), working_dir: working_dir.clone(), }) } else { ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { prompt: queued_data.message.clone(), executor_config: queued_data.executor_config.clone(), working_dir, }) }; let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new)); self.start_execution( &ctx.workspace, &ctx.session, &action, &ExecutionProcessRunReason::CodingAgent, ) .await } } fn failure_exit_status() -> std::process::ExitStatus { #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; ExitStatusExt::from_raw(256) // Exit code 1 (shifted by 8 bits) } #[cfg(windows)] { use std::os::windows::process::ExitStatusExt; ExitStatusExt::from_raw(1) } } #[async_trait] impl ContainerService for LocalContainerService { fn msg_stores(&self) -> &Arc>>> { &self.msg_stores } fn db(&self) -> &DBService { &self.db } fn git(&self) -> &GitService { &self.git } fn notification_service(&self) -> &NotificationService { &self.notification_service } async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError> { let now = Instant::now(); // We debounce touches to avoid excessive database writes, which in SQLites causes DB locks let should_debounce = |last_touch: &Instant| -> bool { now.duration_since(*last_touch) < WORKSPACE_TOUCH_DEBOUNCE }; // Quick check with read lock if self .workspace_touch_times .read() .await .get(&workspace.id) .is_some_and(should_debounce) { return Ok(()); } let mut map = self.workspace_touch_times.write().await; // Clean up stale entries older than the debounce window, reduce memory usage over time map.retain(|_, time| should_debounce(time)); // check in case another thread has touched already if map.get(&workspace.id).is_some_and(should_debounce) { return Ok(()); } map.insert(workspace.id, now); drop(map); Workspace::touch(&self.db.pool, workspace.id).await?; Ok(()) } async fn store_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>) { self.add_db_stream_handle(id, handle).await; } async fn take_db_stream_handle(&self, id: &Uuid) -> Option> { LocalContainerService::take_db_stream_handle(self, id).await } async fn git_branch_prefix(&self) -> String { self.config.read().await.git_branch_prefix.clone() } fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf { PathBuf::from(workspace.container_ref.clone().unwrap_or_default()) } async fn create(&self, workspace: &Workspace) -> Result { let label = workspace.name.as_deref().unwrap_or("workspace"); let workspace_dir_name = LocalContainerService::dir_name_from_workspace(&workspace.id, label); let workspace_dir = WorkspaceManager::get_workspace_base_dir().join(&workspace_dir_name); let (repositories, workspace_inputs) = self.workspace_repo_inputs(workspace.id).await?; let created_workspace = WorkspaceManager::create_workspace( &workspace_dir, &workspace_inputs, &workspace.branch, ) .await .map_err(Self::map_workspace_manager_error)?; // Copy project files and images to workspace self.copy_files_and_images(&created_workspace.workspace_dir, workspace) .await?; Self::create_workspace_config_files(&created_workspace.workspace_dir, &repositories) .await?; Workspace::update_container_ref( &self.db.pool, workspace.id, &created_workspace.workspace_dir.to_string_lossy(), ) .await?; Ok(created_workspace .workspace_dir .to_string_lossy() .to_string()) } async fn delete(&self, workspace: &Workspace) -> Result<(), ContainerError> { self.try_stop(workspace, true).await; self.cleanup_workspace(workspace).await; Ok(()) } async fn ensure_container_exists( &self, workspace: &Workspace, ) -> Result { self.touch(workspace).await?; let (repositories, workspace_inputs) = self.workspace_repo_inputs(workspace.id).await?; let workspace_dir = if let Some(container_ref) = &workspace.container_ref { PathBuf::from(container_ref) } else { let label = workspace.name.as_deref().unwrap_or("workspace"); let workspace_dir_name = LocalContainerService::dir_name_from_workspace(&workspace.id, label); WorkspaceManager::get_workspace_base_dir().join(&workspace_dir_name) }; WorkspaceManager::ensure_workspace_exists( &workspace_dir, &workspace_inputs, &workspace.branch, ) .await .map_err(Self::map_workspace_manager_error)?; if workspace.container_ref.is_none() { Workspace::update_container_ref( &self.db.pool, workspace.id, &workspace_dir.to_string_lossy(), ) .await?; } if workspace.worktree_deleted { Workspace::clear_worktree_deleted(&self.db.pool, workspace.id).await?; } // Copy project files and images (fast no-op if already exist) self.copy_files_and_images(&workspace_dir, workspace) .await?; Self::create_workspace_config_files(&workspace_dir, &repositories).await?; Ok(workspace_dir.to_string_lossy().to_string()) } async fn is_container_clean(&self, workspace: &Workspace) -> Result { let Some(container_ref) = &workspace.container_ref else { return Ok(true); }; let workspace_dir = PathBuf::from(container_ref); if !workspace_dir.exists() { return Ok(true); } let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; for repo in &repositories { let worktree_path = workspace_dir.join(&repo.name); if worktree_path.exists() { let (uncommitted, untracked) = self.git().get_worktree_change_counts(&worktree_path)?; if uncommitted > 0 || untracked > 0 { return Ok(false); } } } Ok(true) } async fn start_execution_inner( &self, workspace: &Workspace, execution_process: &ExecutionProcess, executor_action: &ExecutorAction, ) -> Result<(), ContainerError> { // Get the worktree path let container_ref = workspace .container_ref .as_ref() .ok_or(ContainerError::Other(anyhow!( "Container ref not found for workspace" )))?; let current_dir = PathBuf::from(container_ref); let approvals_service: Arc = match executor_action.base_executor() { Some( BaseCodingAgent::Codex | BaseCodingAgent::ClaudeCode | BaseCodingAgent::Gemini | BaseCodingAgent::QwenCode | BaseCodingAgent::Opencode, ) => ExecutorApprovalBridge::new( self.approvals.clone(), self.db.clone(), self.notification_service.clone(), execution_process.id, ), _ => Arc::new(NoopExecutorApprovalService {}), }; let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); let repo_context = RepoContext::new(current_dir.clone(), repo_names); let config = self.config.read().await; let commit_reminder_enabled = config.commit_reminder_enabled; let commit_reminder_prompt = config .commit_reminder_prompt .clone() .unwrap_or_else(|| DEFAULT_COMMIT_REMINDER_PROMPT.to_string()); drop(config); let mut env = ExecutionEnv::new( repo_context, commit_reminder_enabled, commit_reminder_prompt, ); // Always inject workspace/session context env.insert("VK_WORKSPACE_ID", workspace.id.to_string()); env.insert("VK_WORKSPACE_BRANCH", &workspace.branch); // Create the child and stream, add to execution tracker with timeout let mut spawned = tokio::time::timeout( Duration::from_secs(30), executor_action.spawn(¤t_dir, approvals_service, &env), ) .await .map_err(|_| { ContainerError::Other(anyhow!( "Timeout: process took more than 30 seconds to start" )) })??; self.track_child_msgs_in_store(execution_process.id, &mut spawned.child) .await; self.add_child_to_store(execution_process.id, spawned.child) .await; // Store cancellation token for graceful shutdown if let Some(cancel) = spawned.cancel { self.add_cancellation_token(execution_process.id, cancel) .await; } // Spawn unified exit monitor: watches OS exit and optional executor signal let hn = self.spawn_exit_monitor(&execution_process.id, spawned.exit_signal); self.add_exit_monitor_handle(execution_process.id, hn).await; Ok(()) } async fn stop_execution( &self, execution_process: &ExecutionProcess, status: ExecutionProcessStatus, ) -> Result<(), ContainerError> { let child = self .get_child_from_store(&execution_process.id) .await .ok_or_else(|| { ContainerError::Other(anyhow!("Child process not found for execution")) })?; let exit_code = if status == ExecutionProcessStatus::Completed { Some(0) } else { None }; ExecutionProcess::update_completion(&self.db.pool, execution_process.id, status, exit_code) .await?; // Try graceful cancellation first, then force kill if let Some(cancel) = self.take_cancellation_token(&execution_process.id).await { cancel.cancel(); // Wait for exit monitor to finish gracefully if let Some(monitor_handle) = self.take_exit_monitor_handle(&execution_process.id).await { match tokio::time::timeout(Duration::from_secs(5), monitor_handle).await { Ok(_) => { tracing::debug!("Process {} exited gracefully", execution_process.id); } Err(_) => { tracing::debug!( "Graceful shutdown timed out for process {}, force killing", execution_process.id ); } } } } { let mut child_guard = child.write().await; if let Err(e) = command::kill_process_group(&mut child_guard).await { tracing::error!( "Failed to stop execution process {}: {}", execution_process.id, e ); return Err(e); } } self.remove_child_from_store(&execution_process.id).await; // Mark the process finished in the MsgStore and wait for DB persistence let db_stream_handle = self.take_db_stream_handle(&execution_process.id).await; if let Some(msg) = self.msg_stores.write().await.remove(&execution_process.id) { msg.push_finished(); } if let Some(handle) = db_stream_handle { let _ = tokio::time::timeout(Duration::from_secs(5), handle).await; } tracing::debug!( "Execution process {} stopped successfully", execution_process.id ); // Record after-head commit OID (best-effort) self.update_after_head_commits(execution_process.id).await; Ok(()) } async fn stream_diff( &self, workspace: &Workspace, stats_only: bool, ) -> Result>, ContainerError> { let workspace_repos = WorkspaceRepo::find_by_workspace_id(&self.db.pool, workspace.id).await?; let target_branches: HashMap<_, _> = workspace_repos .iter() .map(|wr| (wr.repo_id, wr.target_branch.clone())) .collect(); let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; let mut streams = Vec::new(); let container_ref = self.ensure_container_exists(workspace).await?; let workspace_root = PathBuf::from(container_ref); for repo in repositories { let worktree_path = workspace_root.join(&repo.name); let branch = &workspace.branch; let Some(target_branch) = target_branches.get(&repo.id) else { tracing::warn!( "Skipping diff stream for repo {}: no target branch configured", repo.name ); continue; }; let base_commit = match self .git() .get_base_commit(&repo.path, branch, target_branch) { Ok(c) => c, Err(e) => { tracing::warn!( "Skipping diff stream for repo {}: failed to get base commit: {}", repo.name, e ); continue; } }; let stream = self .create_live_diff_stream(diff_stream::DiffStreamArgs { git_service: self.git().clone(), db: self.db().clone(), workspace_id: workspace.id, repo_id: repo.id, repo_path: repo.path.clone(), worktree_path: worktree_path.clone(), branch: branch.to_string(), target_branch: target_branch.clone(), base_commit: base_commit.clone(), stats_only, path_prefix: Some(repo.name.clone()), }) .await?; streams.push(Box::pin(stream)); } if streams.is_empty() { return Ok(Box::pin(futures::stream::empty())); } // Merge all streams into one Ok(Box::pin(futures::stream::select_all(streams))) } async fn try_commit_changes(&self, ctx: &ExecutionContext) -> Result { if !matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent | ExecutionProcessRunReason::CleanupScript, ) { return Ok(false); } let message = self.get_commit_message(ctx).await; let container_ref = ctx .workspace .container_ref .as_ref() .ok_or_else(|| ContainerError::Other(anyhow!("Container reference not found")))?; let workspace_root = PathBuf::from(container_ref); let repos_with_changes = self.check_repos_for_changes(&workspace_root, &ctx.repos)?; if repos_with_changes.is_empty() { tracing::debug!("No changes to commit in any repository"); return Ok(false); } Ok(self.commit_repos(repos_with_changes, &message)) } /// Copy files from the original project directory to the worktree. /// Skips files that already exist at target with same size. async fn copy_project_files( &self, source_dir: &Path, target_dir: &Path, copy_files: &str, ) -> Result<(), ContainerError> { let source_dir = source_dir.to_path_buf(); let target_dir = target_dir.to_path_buf(); let copy_files = copy_files.to_string(); tokio::time::timeout( std::time::Duration::from_secs(30), tokio::task::spawn_blocking(move || { copy::copy_project_files_impl(&source_dir, &target_dir, ©_files) }), ) .await .map_err(|_| ContainerError::Other(anyhow!("Copy project files timed out after 30s")))? .map_err(|e| ContainerError::Other(anyhow!("Copy files task failed: {e}")))? } async fn kill_all_running_processes(&self) -> Result<(), ContainerError> { tracing::info!("Killing all running processes"); let running_processes = ExecutionProcess::find_running(&self.db.pool).await?; tracing::info!( "Found {} running processes to kill", running_processes.len() ); for process in running_processes { tracing::info!( "Killing process: id={}, run_reason={:?}", process.id, process.run_reason ); if let Err(error) = self .stop_execution(&process, ExecutionProcessStatus::Killed) .await { tracing::error!( "Failed to cleanly kill running execution process {:?}: {:?}", process, error ); } else { tracing::info!("Successfully killed process: id={}", process.id); } } Ok(()) } } fn success_exit_status() -> std::process::ExitStatus { #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; ExitStatusExt::from_raw(0) } #[cfg(windows)] { use std::os::windows::process::ExitStatusExt; ExitStatusExt::from_raw(0) } } ================================================ FILE: crates/local-deployment/src/copy.rs ================================================ use std::{ collections::HashSet, fs, path::{Path, PathBuf}, }; use anyhow::anyhow; use globwalk::GlobWalkerBuilder; use services::services::container::ContainerError; /// Normalize pattern for cross-platform glob matching (convert backslashes to forward slashes) fn normalize_pattern(pattern: &str) -> String { pattern.replace('\\', "/") } /// Copy project files from source to target directory based on glob patterns. /// Skips files that already exist at target with same size. pub(crate) fn copy_project_files_impl( source_dir: &Path, target_dir: &Path, copy_files: &str, ) -> Result<(), ContainerError> { let patterns: Vec<&str> = copy_files .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .collect(); // Track files to avoid duplicates let mut seen = HashSet::new(); for pattern in patterns { let pattern = normalize_pattern(pattern); let pattern_path = source_dir.join(&pattern); if pattern_path.is_file() { if let Err(e) = copy_single_file(&pattern_path, source_dir, target_dir, &mut seen) { tracing::warn!( "Failed to copy file {} (from {}): {}", pattern, pattern_path.display(), e ); } continue; } let glob_pattern = if pattern_path.is_dir() { // For directories, append /** to match all contents recursively format!("{pattern}/**") } else { pattern.clone() }; let walker = match GlobWalkerBuilder::from_patterns(source_dir, &[&glob_pattern]) .file_type(globwalk::FileType::FILE) .build() { Ok(w) => w, Err(e) => { tracing::warn!("Invalid glob pattern '{glob_pattern}': {e}"); continue; } }; for entry in walker.flatten() { if let Err(e) = copy_single_file(entry.path(), source_dir, target_dir, &mut seen) { tracing::warn!("Failed to copy file {:?}: {e}", entry.path()); } } } Ok(()) } fn copy_single_file( source_file: &Path, source_root: &Path, target_root: &Path, seen: &mut HashSet, ) -> Result { let canonical_source = source_root.canonicalize()?; let canonical_file = source_file.canonicalize()?; // Validate path is within source_dir if !canonical_file.starts_with(canonical_source) { return Err(ContainerError::Other(anyhow!( "File {source_file:?} is outside project directory" ))); } if !seen.insert(canonical_file.clone()) { return Ok(false); } let relative_path = source_file.strip_prefix(source_root).map_err(|e| { ContainerError::Other(anyhow!( "Failed to get relative path for {source_file:?}: {e}" )) })?; let target_file = target_root.join(relative_path); if target_file.exists() { return Ok(false); } if let Some(parent) = target_file.parent() && !parent.exists() { fs::create_dir_all(parent)?; } fs::copy(source_file, &target_file)?; Ok(true) } #[cfg(test)] mod tests { use std::fs; use tempfile::TempDir; use super::*; #[test] fn test_copy_project_files_mixed_patterns() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); fs::write(source_dir.path().join(".env"), "secret").unwrap(); fs::write(source_dir.path().join("config.json"), "{}").unwrap(); let src_dir = source_dir.path().join("src"); fs::create_dir(&src_dir).unwrap(); fs::write(src_dir.join("main.rs"), "code").unwrap(); fs::write(src_dir.join("lib.rs"), "lib").unwrap(); let config_dir = source_dir.path().join("config"); fs::create_dir(&config_dir).unwrap(); fs::write(config_dir.join("app.toml"), "config").unwrap(); copy_project_files_impl( source_dir.path(), target_dir.path(), ".env, *.json, src, config", ) .unwrap(); assert!(target_dir.path().join(".env").exists()); assert!(target_dir.path().join("config.json").exists()); assert!(target_dir.path().join("src/main.rs").exists()); assert!(target_dir.path().join("src/lib.rs").exists()); assert!(target_dir.path().join("config/app.toml").exists()); } #[test] fn test_copy_project_files_nonexistent_pattern_ok() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); let result = copy_project_files_impl(source_dir.path(), target_dir.path(), "nonexistent.txt"); assert!(result.is_ok()); assert!(!target_dir.path().join("nonexistent.txt").exists()); } #[test] fn test_copy_project_files_empty_pattern_ok() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); let result = copy_project_files_impl(source_dir.path(), target_dir.path(), ""); assert!(result.is_ok()); assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0); } #[test] fn test_copy_project_files_whitespace_handling() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); fs::write(source_dir.path().join("test.txt"), "content").unwrap(); copy_project_files_impl(source_dir.path(), target_dir.path(), " test.txt , ").unwrap(); assert!(target_dir.path().join("test.txt").exists()); } #[test] fn test_copy_project_files_nested_directory() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); let config_dir = source_dir.path().join("config"); fs::create_dir(&config_dir).unwrap(); fs::write(config_dir.join("app.json"), "{}").unwrap(); let nested_dir = config_dir.join("nested"); fs::create_dir(&nested_dir).unwrap(); fs::write(nested_dir.join("deep.txt"), "deep").unwrap(); copy_project_files_impl(source_dir.path(), target_dir.path(), "config").unwrap(); assert!(target_dir.path().join("config/app.json").exists()); assert!(target_dir.path().join("config/nested/deep.txt").exists()); } #[test] fn test_copy_project_files_outside_source_skips_without_copying() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); // Create file outside of source directory (one level up) let parent_dir = source_dir.path().parent().unwrap().to_path_buf(); let outside_file = parent_dir.join("secret.txt"); fs::write(&outside_file, "secret").unwrap(); // Pattern referencing parent directory should resolve to outside_file and be rejected let result = copy_project_files_impl(source_dir.path(), target_dir.path(), "../secret.txt"); assert!(result.is_ok()); assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0); } #[test] fn test_copy_project_files_recursive_glob_extension_filter() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); // Create nested directory structure with YAML files let config_dir = source_dir.path().join("config"); fs::create_dir(&config_dir).unwrap(); fs::write(config_dir.join("app.yml"), "app: config").unwrap(); fs::write(config_dir.join("db.json"), "{}").unwrap(); let nested_dir = config_dir.join("nested"); fs::create_dir(&nested_dir).unwrap(); fs::write(nested_dir.join("settings.yml"), "settings: value").unwrap(); fs::write(nested_dir.join("other.txt"), "text").unwrap(); let deep_dir = nested_dir.join("deep"); fs::create_dir(&deep_dir).unwrap(); fs::write(deep_dir.join("deep.yml"), "deep: config").unwrap(); // Copy all YAML files recursively copy_project_files_impl(source_dir.path(), target_dir.path(), "config/**/*.yml").unwrap(); // Verify only YAML files are copied assert!(target_dir.path().join("config/app.yml").exists()); assert!( target_dir .path() .join("config/nested/settings.yml") .exists() ); assert!( target_dir .path() .join("config/nested/deep/deep.yml") .exists() ); // Verify non-YAML files are not copied assert!(!target_dir.path().join("config/db.json").exists()); assert!(!target_dir.path().join("config/nested/other.txt").exists()); } #[test] fn test_copy_project_files_duplicate_patterns_ok() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); // Create source files let src_dir = source_dir.path().join("src"); fs::create_dir(&src_dir).unwrap(); fs::write(src_dir.join("lib.rs"), "lib code").unwrap(); fs::write(src_dir.join("main.rs"), "main code").unwrap(); // Copy with overlapping patterns: glob and specific file copy_project_files_impl(source_dir.path(), target_dir.path(), "src/*.rs, src/lib.rs") .unwrap(); // Verify file exists once (deduplication works) let target_file = target_dir.path().join("src/lib.rs"); assert!(target_file.exists()); assert_eq!(fs::read_to_string(target_file).unwrap(), "lib code"); // Verify other file from glob is also copied assert!(target_dir.path().join("src/main.rs").exists()); } #[test] fn test_copy_project_files_single_file_path() { let source_dir = TempDir::new().unwrap(); let target_dir = TempDir::new().unwrap(); // Create source file let src_dir = source_dir.path().join("src"); fs::create_dir(&src_dir).unwrap(); fs::write(src_dir.join("lib.rs"), "library code").unwrap(); // Copy single file by exact path (exercises fast path) copy_project_files_impl(source_dir.path(), target_dir.path(), "src/lib.rs").unwrap(); // Verify file is copied let target_file = target_dir.path().join("src/lib.rs"); assert!(target_file.exists()); assert_eq!(fs::read_to_string(target_file).unwrap(), "library code"); } #[cfg(unix)] #[test] fn test_symlink_loop_is_skipped() { use std::os::unix::fs::symlink; let src = TempDir::new().unwrap(); let dst = TempDir::new().unwrap(); let loop_dir = src.path().join("loop"); std::fs::create_dir(&loop_dir).unwrap(); symlink(".", loop_dir.join("self")).unwrap(); // loop/self -> loop copy_project_files_impl(src.path(), dst.path(), "loop").unwrap(); assert_eq!(std::fs::read_dir(dst.path()).unwrap().count(), 0); } } ================================================ FILE: crates/local-deployment/src/lib.rs ================================================ use std::{collections::HashMap, sync::Arc}; use api_types::LoginStatus; use async_trait::async_trait; use db::DBService; use deployment::{Deployment, DeploymentError, RemoteClientNotConfigured}; use executors::profile::ExecutorConfigs; use git::GitService; use relay_control::{RelayControl, signing::RelaySigningService}; use server_info::ServerInfo; use services::services::{ analytics::{AnalyticsConfig, AnalyticsContext, AnalyticsService, generate_user_id}, approvals::Approvals, auth::AuthContext, config::{Config, load_config_from_file, save_config_to_file}, container::ContainerService, events::EventService, file::FileService, file_search::FileSearchCache, filesystem::FilesystemService, oauth_credentials::OAuthCredentials, pr_monitor::PrMonitorService, queued_message::QueuedMessageService, remote_client::{RemoteClient, RemoteClientError}, repo::RepoService, }; use tokio::sync::RwLock; use trusted_key_auth::runtime::TrustedKeyAuthRuntime; use utils::{ assets::{config_path, credentials_path, server_signing_key_path, trusted_keys_path}, msg_store::MsgStore, }; use uuid::Uuid; use workspace_manager::WorkspaceManager; use worktree_manager::WorktreeManager; use crate::{container::LocalContainerService, pty::PtyService}; mod command; pub mod container; mod copy; pub mod pty; #[derive(Clone)] pub struct LocalDeployment { config: Arc>, user_id: String, db: DBService, workspace_manager: WorkspaceManager, analytics: Option, container: LocalContainerService, git: GitService, repo: RepoService, file: FileService, filesystem: FilesystemService, events: EventService, file_search_cache: Arc, approvals: Approvals, queued_message_service: QueuedMessageService, remote_client: Result, shared_api_base: Option, auth_context: AuthContext, oauth_handoffs: Arc>>, trusted_key_auth: TrustedKeyAuthRuntime, relay_signing: RelaySigningService, relay_control: Arc, server_info: Arc, pty: PtyService, } #[derive(Debug, Clone)] struct PendingHandoff { provider: String, app_verifier: String, } #[async_trait] impl Deployment for LocalDeployment { async fn new() -> Result { // Run one-time process logs migration from DB to filesystem services::services::execution_process::migrate_execution_logs_to_files() .await .map_err(|e| DeploymentError::Other(anyhow::anyhow!("Migration failed: {}", e)))?; let mut raw_config = load_config_from_file(&config_path()).await; let profiles = ExecutorConfigs::get_cached(); if !raw_config.onboarding_acknowledged && let Ok(recommended_executor) = profiles.get_recommended_executor_profile().await { raw_config.executor_profile = recommended_executor; } // Check if app version has changed and set release notes flag { let current_version = utils::version::APP_VERSION; let stored_version = raw_config.last_app_version.as_deref(); if stored_version != Some(current_version) { // Show release notes only if this is an upgrade (not first install) raw_config.show_release_notes = stored_version.is_some(); raw_config.last_app_version = Some(current_version.to_string()); } } // Always save config (may have been migrated or version updated) save_config_to_file(&raw_config, &config_path()).await?; if let Some(workspace_dir) = &raw_config.workspace_dir { let path = utils::path::expand_tilde(workspace_dir); WorktreeManager::set_workspace_dir_override(path); } let config = Arc::new(RwLock::new(raw_config)); let user_id = generate_user_id(); let analytics = AnalyticsConfig::new().map(AnalyticsService::new); let git = GitService::new(); let repo = RepoService::new(); let msg_stores = Arc::new(RwLock::new(HashMap::new())); let filesystem = FilesystemService::new(); // Create shared components for EventService let events_msg_store = Arc::new(MsgStore::new()); let events_entry_count = Arc::new(RwLock::new(0)); // Create DB with event hooks let db = { let hook = EventService::create_hook( events_msg_store.clone(), events_entry_count.clone(), DBService::new().await?, // Temporary DB service for the hook ); DBService::new_with_after_connect(hook).await? }; let file = FileService::new(db.clone().pool)?; { let file_service = file.clone(); tokio::spawn(async move { tracing::info!("Starting orphaned file cleanup..."); if let Err(e) = file_service.delete_orphaned_files().await { tracing::error!("Failed to clean up orphaned files: {}", e); } }); } let approvals = Approvals::new(); let queued_message_service = QueuedMessageService::new(); let oauth_credentials = Arc::new(OAuthCredentials::new(credentials_path())); if let Err(e) = oauth_credentials.load().await { tracing::warn!(?e, "failed to load OAuth credentials"); } let profile_cache = Arc::new(RwLock::new(None)); let auth_context = AuthContext::new(oauth_credentials.clone(), profile_cache.clone()); let api_base = std::env::var("VK_SHARED_API_BASE") .ok() .or_else(|| option_env!("VK_SHARED_API_BASE").map(|s| s.to_string())); let remote_client = match &api_base { Some(url) => match RemoteClient::new(url, auth_context.clone()) { Ok(client) => { tracing::info!("Remote client initialized with URL: {}", url); Ok(client) } Err(e) => { tracing::error!(?e, "failed to create remote client"); Err(RemoteClientNotConfigured) } }, None => { tracing::info!("VK_SHARED_API_BASE not set; remote features disabled"); Err(RemoteClientNotConfigured) } }; let oauth_handoffs = Arc::new(RwLock::new(HashMap::new())); let trusted_key_auth = TrustedKeyAuthRuntime::new(trusted_keys_path()); let relay_signing = RelaySigningService::load_or_generate(&server_signing_key_path()) .expect("Failed to load or generate server signing key"); let relay_control = Arc::new(RelayControl::new()); let server_info = Arc::new(ServerInfo::new()); // We need to make analytics accessible to the ContainerService // TODO: Handle this more gracefully let analytics_ctx = analytics.as_ref().map(|s| AnalyticsContext { user_id: user_id.clone(), analytics_service: s.clone(), }); let workspace_manager = WorkspaceManager::new(db.clone()); let container = LocalContainerService::new( db.clone(), workspace_manager.clone(), msg_stores.clone(), config.clone(), git.clone(), file.clone(), analytics_ctx, approvals.clone(), queued_message_service.clone(), remote_client.clone().ok(), ) .await; let events = EventService::new(db.clone(), events_msg_store, events_entry_count); let file_search_cache = Arc::new(FileSearchCache::new()); let pty = PtyService::new(); { let db = db.clone(); let analytics = analytics.as_ref().map(|s| AnalyticsContext { user_id: user_id.clone(), analytics_service: s.clone(), }); let container = container.clone(); let rc = remote_client.clone().ok(); PrMonitorService::spawn(db, analytics, container, rc).await; } let deployment = Self { config, user_id, db, workspace_manager, analytics, container, git, repo, file, filesystem, events, file_search_cache, approvals, queued_message_service, remote_client, shared_api_base: api_base, auth_context, oauth_handoffs, trusted_key_auth, relay_signing, relay_control, server_info, pty, }; Ok(deployment) } fn user_id(&self) -> &str { &self.user_id } fn config(&self) -> &Arc> { &self.config } fn db(&self) -> &DBService { &self.db } fn analytics(&self) -> &Option { &self.analytics } fn container(&self) -> &impl ContainerService { &self.container } fn git(&self) -> &GitService { &self.git } fn repo(&self) -> &RepoService { &self.repo } fn file(&self) -> &FileService { &self.file } fn filesystem(&self) -> &FilesystemService { &self.filesystem } fn events(&self) -> &EventService { &self.events } fn file_search_cache(&self) -> &Arc { &self.file_search_cache } fn approvals(&self) -> &Approvals { &self.approvals } fn queued_message_service(&self) -> &QueuedMessageService { &self.queued_message_service } fn auth_context(&self) -> &AuthContext { &self.auth_context } fn relay_control(&self) -> &Arc { &self.relay_control } fn relay_signing(&self) -> &RelaySigningService { &self.relay_signing } fn server_info(&self) -> &Arc { &self.server_info } fn trusted_key_auth(&self) -> &TrustedKeyAuthRuntime { &self.trusted_key_auth } fn shared_api_base(&self) -> Option { self.shared_api_base.clone() } } impl LocalDeployment { pub fn workspace_manager(&self) -> &WorkspaceManager { &self.workspace_manager } pub fn remote_client(&self) -> Result { self.remote_client.clone() } pub async fn get_login_status(&self) -> LoginStatus { if self.auth_context.get_credentials().await.is_none() { self.auth_context.clear_profile().await; return LoginStatus::LoggedOut; }; if let Some(cached_profile) = self.auth_context.cached_profile().await { return LoginStatus::LoggedIn { profile: cached_profile, }; } let Ok(client) = self.remote_client() else { return LoginStatus::LoggedOut; }; match client.profile().await { Ok(profile) => { self.auth_context.set_profile(profile.clone()).await; LoginStatus::LoggedIn { profile } } Err(RemoteClientError::Auth) => { let _ = self.auth_context.clear_credentials().await; self.auth_context.clear_profile().await; LoginStatus::LoggedOut } Err(_) => LoginStatus::LoggedOut, } } pub async fn store_oauth_handoff( &self, handoff_id: Uuid, provider: String, app_verifier: String, ) { self.oauth_handoffs.write().await.insert( handoff_id, PendingHandoff { provider, app_verifier, }, ); } pub async fn take_oauth_handoff(&self, handoff_id: &Uuid) -> Option<(String, String)> { self.oauth_handoffs .write() .await .remove(handoff_id) .map(|state| (state.provider, state.app_verifier)) } pub fn pty(&self) -> &PtyService { &self.pty } } ================================================ FILE: crates/local-deployment/src/pty.rs ================================================ use std::{ collections::HashMap, io::{Read, Write}, path::PathBuf, sync::{Arc, Mutex}, thread, }; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use thiserror::Error; use tokio::sync::mpsc; use utils::shell::get_interactive_shell; use uuid::Uuid; #[derive(Debug, Error)] pub enum PtyError { #[error("Failed to create PTY: {0}")] CreateFailed(String), #[error("Session not found: {0}")] SessionNotFound(Uuid), #[error("Failed to write to PTY: {0}")] WriteFailed(String), #[error("Failed to resize PTY: {0}")] ResizeFailed(String), #[error("Session already closed")] SessionClosed, } struct PtySession { writer: Box, master: Box, _output_handle: thread::JoinHandle<()>, closed: bool, } #[derive(Clone)] pub struct PtyService { sessions: Arc>>, } impl PtyService { pub fn new() -> Self { Self { sessions: Arc::new(Mutex::new(HashMap::new())), } } pub async fn create_session( &self, working_dir: PathBuf, cols: u16, rows: u16, ) -> Result<(Uuid, mpsc::UnboundedReceiver>), PtyError> { let session_id = Uuid::new_v4(); let (output_tx, output_rx) = mpsc::unbounded_channel(); let shell = get_interactive_shell().await; let result = tokio::task::spawn_blocking(move || { let pty_system = NativePtySystem::default(); let pty_pair = pty_system .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .map_err(|e| PtyError::CreateFailed(e.to_string()))?; let mut cmd = CommandBuilder::new(&shell); cmd.cwd(&working_dir); // Configure shell-specific options let shell_name = shell.file_name().and_then(|n| n.to_str()).unwrap_or(""); if shell_name == "powershell.exe" || shell_name == "pwsh.exe" { // PowerShell: use -NoLogo for cleaner startup cmd.arg("-NoLogo"); } else if shell_name == "cmd.exe" { // cmd.exe: no special args needed } else { // Unix shells cmd.env("VIBE_KANBAN_TERMINAL", "1"); if shell_name == "bash" { cmd.env("PROMPT_COMMAND", r#"PS1='$ '; unset PROMPT_COMMAND"#); } else if shell_name == "zsh" { // PROMPT is set after spawning } else { cmd.env("PS1", "$ "); } } cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); let child = pty_pair .slave .spawn_command(cmd) .map_err(|e| PtyError::CreateFailed(e.to_string()))?; let mut writer = pty_pair .master .take_writer() .map_err(|e| PtyError::CreateFailed(e.to_string()))?; if shell_name == "zsh" { let _ = writer.write_all(b" PROMPT='$ '; RPROMPT=''\n"); let _ = writer.flush(); let _ = writer.write_all(b"\x0c"); let _ = writer.flush(); } let mut reader = pty_pair .master .try_clone_reader() .map_err(|e| PtyError::CreateFailed(e.to_string()))?; let output_handle = thread::spawn(move || { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { if output_tx.send(buf[..n].to_vec()).is_err() { break; } } Err(_) => break, } } drop(child); }); Ok::<_, PtyError>((pty_pair.master, writer, output_handle)) }) .await .map_err(|e| PtyError::CreateFailed(e.to_string()))??; let (master, writer, output_handle) = result; let session = PtySession { writer, master, _output_handle: output_handle, closed: false, }; self.sessions .lock() .map_err(|e| PtyError::CreateFailed(e.to_string()))? .insert(session_id, session); Ok((session_id, output_rx)) } pub async fn write(&self, session_id: Uuid, data: &[u8]) -> Result<(), PtyError> { let mut sessions = self .sessions .lock() .map_err(|e| PtyError::WriteFailed(e.to_string()))?; let session = sessions .get_mut(&session_id) .ok_or(PtyError::SessionNotFound(session_id))?; if session.closed { return Err(PtyError::SessionClosed); } session .writer .write_all(data) .map_err(|e| PtyError::WriteFailed(e.to_string()))?; session .writer .flush() .map_err(|e| PtyError::WriteFailed(e.to_string()))?; Ok(()) } pub async fn resize(&self, session_id: Uuid, cols: u16, rows: u16) -> Result<(), PtyError> { let sessions = self .sessions .lock() .map_err(|e| PtyError::ResizeFailed(e.to_string()))?; let session = sessions .get(&session_id) .ok_or(PtyError::SessionNotFound(session_id))?; if session.closed { return Err(PtyError::SessionClosed); } session .master .resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .map_err(|e| PtyError::ResizeFailed(e.to_string()))?; Ok(()) } pub async fn close_session(&self, session_id: Uuid) -> Result<(), PtyError> { if let Some(mut session) = self .sessions .lock() .map_err(|_| PtyError::SessionClosed)? .remove(&session_id) { session.closed = true; } Ok(()) } pub fn session_exists(&self, session_id: &Uuid) -> bool { self.sessions .lock() .map(|s| s.contains_key(session_id)) .unwrap_or(false) } } impl Default for PtyService { fn default() -> Self { Self::new() } } ================================================ FILE: crates/mcp/Cargo.toml ================================================ [package] name = "mcp" version = "0.1.33" edition = "2024" autobins = false [[bin]] name = "vibe-kanban-mcp" path = "src/bin/vibe_kanban_mcp.rs" [lints.clippy] uninlined-format-args = "allow" [dependencies] api-types = { path = "../api-types" } db = { path = "../db" } executors = { path = "../executors" } utils = { path = "../utils" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } rmcp = { version = "0.5.0", features = ["server", "transport-io"] } schemars = { workspace = true } sentry = { version = "0.46.2", default-features = false, features = ["anyhow", "backtrace", "panic", "debug-images", "reqwest", "rustls"] } reqwest = { workspace = true } rustls = { workspace = true } regex = "1" ================================================ FILE: crates/mcp/src/bin/vibe_kanban_mcp.rs ================================================ use mcp::task_server::McpServer; use rmcp::{ServiceExt, transport::stdio}; use tracing_subscriber::{EnvFilter, prelude::*}; use utils::{ port_file::read_port_file, sentry::{self as sentry_utils, SentrySource, sentry_layer}, }; const HOST_ENV: &str = "MCP_HOST"; const PORT_ENV: &str = "MCP_PORT"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum McpLaunchMode { Global, Orchestrator, } #[derive(Debug, Clone, PartialEq, Eq)] struct LaunchConfig { mode: McpLaunchMode, } fn main() -> anyhow::Result<()> { let launch_config = resolve_launch_config()?; tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap() .block_on(async move { let version = env!("CARGO_PKG_VERSION"); init_process_logging("vibe-kanban-mcp", version); let base_url = resolve_base_url("vibe-kanban-mcp").await?; let LaunchConfig { mode } = launch_config; let server = match mode { McpLaunchMode::Global => McpServer::new_global(&base_url), McpLaunchMode::Orchestrator => McpServer::new_orchestrator(&base_url), }; let service = server.init().await?.serve(stdio()).await.map_err(|error| { tracing::error!("serving error: {:?}", error); error })?; service.waiting().await?; Ok(()) }) } fn resolve_launch_config() -> anyhow::Result { resolve_launch_config_from_iter(std::env::args().skip(1)) } fn resolve_launch_config_from_iter(mut args: I) -> anyhow::Result where I: Iterator, { let mut mode = None; while let Some(arg) = args.next() { match arg.as_str() { "--mode" => { mode = Some(args.next().ok_or_else(|| { anyhow::anyhow!("Missing value for --mode. Expected 'global' or 'orchestrator'") })?); } "-h" | "--help" => { println!("Usage: vibe-kanban-mcp --mode "); std::process::exit(0); } _ => { return Err(anyhow::anyhow!( "Unknown argument '{arg}'. Usage: vibe-kanban-mcp --mode " )); } } } let mode = match mode .as_deref() .unwrap_or("global") .trim() .to_ascii_lowercase() .as_str() { "global" => McpLaunchMode::Global, "orchestrator" => McpLaunchMode::Orchestrator, value => { return Err(anyhow::anyhow!( "Invalid MCP mode '{value}'. Expected 'global' or 'orchestrator'" )); } }; Ok(LaunchConfig { mode }) } async fn resolve_base_url(log_prefix: &str) -> anyhow::Result { if let Ok(url) = std::env::var("VIBE_BACKEND_URL") { tracing::info!( "[{}] Using backend URL from VIBE_BACKEND_URL: {}", log_prefix, url ); return Ok(url); } let host = std::env::var(HOST_ENV) .or_else(|_| std::env::var("HOST")) .unwrap_or_else(|_| "127.0.0.1".to_string()); let port = match std::env::var(PORT_ENV) .or_else(|_| std::env::var("BACKEND_PORT")) .or_else(|_| std::env::var("PORT")) { Ok(port_str) => { tracing::info!("[{}] Using port from environment: {}", log_prefix, port_str); port_str .parse::() .map_err(|error| anyhow::anyhow!("Invalid port value '{}': {}", port_str, error))? } Err(_) => { let port = read_port_file("vibe-kanban").await?; tracing::info!("[{}] Using port from port file: {}", log_prefix, port); port } }; let url = format!("http://{}:{}", host, port); tracing::info!("[{}] Using backend URL: {}", log_prefix, url); Ok(url) } fn init_process_logging(log_prefix: &str, version: &str) { rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); sentry_utils::init_once(SentrySource::Mcp); tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) .with_filter(EnvFilter::new("debug")), ) .with(sentry_layer()) .init(); tracing::debug!( "[{}] Starting Vibe Kanban MCP server version {}...", log_prefix, version ); } #[cfg(test)] mod tests { use super::{LaunchConfig, McpLaunchMode, resolve_launch_config_from_iter}; #[test] fn orchestrator_mode_does_not_require_session_id() { let config = resolve_launch_config_from_iter( ["--mode".to_string(), "orchestrator".to_string()].into_iter(), ) .expect("config should parse"); assert_eq!( config, LaunchConfig { mode: McpLaunchMode::Orchestrator } ); } #[test] fn session_id_flag_is_rejected() { let error = resolve_launch_config_from_iter( [ "--mode".to_string(), "orchestrator".to_string(), "--session-id".to_string(), "x".to_string(), ] .into_iter(), ) .expect_err("session id flag should be rejected"); assert!( error .to_string() .contains("Unknown argument '--session-id'") ); } } ================================================ FILE: crates/mcp/src/lib.rs ================================================ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct ApiResponseEnvelope { pub success: bool, pub data: Option, pub message: Option, } pub mod task_server; ================================================ FILE: crates/mcp/src/task_server/handler.rs ================================================ use rmcp::{ ServerHandler, model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, tool_handler, }; use super::{McpMode, McpServer}; #[tool_handler] impl ServerHandler for McpServer { fn get_info(&self) -> ServerInfo { let mut tool_names = self .tool_router .list_all() .into_iter() .map(|tool| format!("'{}'", tool.name)) .collect::>(); tool_names.sort(); let preamble = match self.mode() { McpMode::Global => { "A Vibe Kanban MCP server for task, issue, repository, workspace, and session management." } McpMode::Orchestrator => { "An orchestrator-scoped Vibe Kanban MCP server with tools limited to the configured workspace and orchestrator session context." } }; let mut instruction = format!( "{} Use list/read tools first when you need IDs or current state. TOOLS: {}.", preamble, tool_names.join(", ") ); if self.context.is_some() { instruction = format!( "Use 'get_context' to fetch project, issue, workspace, and orchestrator-session metadata for the active MCP context when available. {}", instruction ); } ServerInfo { protocol_version: ProtocolVersion::V_2025_03_26, capabilities: ServerCapabilities::builder().enable_tools().build(), server_info: Implementation { name: "vibe-kanban-mcp".to_string(), version: "1.0.0".to_string(), }, instructions: Some(instruction), } } } ================================================ FILE: crates/mcp/src/task_server/mod.rs ================================================ mod handler; mod tools; use std::path::Path; use anyhow::Context; use db::models::{requests::ContainerQuery, workspace::WorkspaceContext}; use rmcp::{handler::server::tool::ToolRouter, schemars}; use serde::{Deserialize, Serialize}; use uuid::Uuid; pub(crate) use crate::ApiResponseEnvelope; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] pub struct McpRepoContext { #[schemars(description = "The unique identifier of the repository")] pub repo_id: Uuid, #[schemars(description = "The name of the repository")] pub repo_name: String, #[schemars(description = "The target branch for this repository in this workspace")] pub target_branch: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] pub struct McpContext { #[schemars(description = "The organization ID (if workspace is linked to remote)")] pub organization_id: Option, #[schemars(description = "The remote project ID (if workspace is linked to remote)")] pub project_id: Option, #[schemars(description = "The remote issue ID (if workspace is linked to a remote issue)")] pub issue_id: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schemars(description = "The orchestrator session ID when running in orchestrator mode")] pub orchestrator_session_id: Option, pub workspace_id: Uuid, pub workspace_branch: String, #[schemars( description = "Repository info and target branches for each repo in this workspace" )] pub workspace_repos: Vec, } #[derive(Debug, Clone)] pub enum McpMode { Global, Orchestrator, } #[derive(Debug, Clone)] pub struct McpServer { client: reqwest::Client, base_url: String, tool_router: ToolRouter, context: Option, mode: McpMode, } impl McpServer { pub fn new_global(base_url: &str) -> Self { Self { client: reqwest::Client::new(), base_url: base_url.to_string(), tool_router: Self::global_mode_router(), context: None, mode: McpMode::Global, } } pub fn new_orchestrator(base_url: &str) -> Self { Self { client: reqwest::Client::new(), base_url: base_url.to_string(), tool_router: Self::orchestrator_mode_router(), context: None, mode: McpMode::Orchestrator, } } fn url(&self, path: &str) -> String { format!( "{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/') ) } pub async fn init(mut self) -> anyhow::Result { let context = self.fetch_context_at_startup().await?; if context.is_none() { self.tool_router.map.remove("get_context"); tracing::debug!("VK context not available, get_context tool will not be registered"); } else { tracing::info!("VK context loaded, get_context tool available"); } self.context = context; Ok(self) } pub fn mode(&self) -> &McpMode { &self.mode } async fn fetch_context_at_startup(&self) -> anyhow::Result> { let current_dir = std::env::current_dir().context("Failed to resolve current directory")?; let canonical_path = current_dir.canonicalize().unwrap_or(current_dir); let normalized_path = utils::path::normalize_macos_private_alias(&canonical_path); match self.try_fetch_attempt_context(&normalized_path).await { Ok(Some(ctx)) => Ok(Some( self.build_mcp_context_from_workspace_context(&ctx).await, )), Ok(None) | Err(_) if matches!(self.mode(), McpMode::Global) => Ok(None), Ok(None) => anyhow::bail!( "Failed to load orchestrator MCP context from /api/containers/attempt-context" ), Err(error) => Err(error.context("Failed to load orchestrator MCP context")), } } async fn try_fetch_attempt_context( &self, path: &Path, ) -> anyhow::Result> { let url = self.url("/api/containers/attempt-context"); let query = ContainerQuery { container_ref: path.to_string_lossy().to_string(), }; let response = tokio::time::timeout( std::time::Duration::from_millis(500), self.client.get(&url).query(&query).send(), ) .await .context("Timed out fetching /api/containers/attempt-context")? .context("Failed to fetch /api/containers/attempt-context")?; if !response.status().is_success() { return Ok(None); } let api_response: ApiResponseEnvelope = response .json() .await .context("Failed to parse /api/containers/attempt-context response")?; if !api_response.success { return Ok(None); } Ok(api_response.data) } async fn build_mcp_context_from_workspace_context(&self, ctx: &WorkspaceContext) -> McpContext { let workspace_repos: Vec = ctx .workspace_repos .iter() .map(|rwb| McpRepoContext { repo_id: rwb.repo.id, repo_name: rwb.repo.name.clone(), target_branch: rwb.target_branch.clone(), }) .collect(); let workspace_id = ctx.workspace.id; let workspace_branch = ctx.workspace.branch.clone(); let orchestrator_session_id = if matches!(self.mode(), McpMode::Orchestrator) { ctx.orchestrator_session_id } else { None }; let (project_id, issue_id, organization_id) = self .fetch_remote_workspace_context(workspace_id) .await .unwrap_or((None, None, None)); McpContext { organization_id, project_id, issue_id, orchestrator_session_id, workspace_id, workspace_branch, workspace_repos, } } async fn fetch_remote_workspace_context( &self, local_workspace_id: Uuid, ) -> Option<(Option, Option, Option)> { let url = self.url(&format!( "/api/remote/workspaces/by-local-id/{}", local_workspace_id )); let response = tokio::time::timeout( std::time::Duration::from_millis(2000), self.client.get(&url).send(), ) .await .ok()? .ok()?; if !response.status().is_success() { return None; } let api_response: ApiResponseEnvelope = response.json().await.ok()?; if !api_response.success { return None; } let remote_ws = api_response.data?; let project_id = remote_ws.project_id; // Fetch the project to get organization_id let org_id = self.fetch_remote_organization_id(project_id).await; Some((Some(project_id), remote_ws.issue_id, org_id)) } async fn fetch_remote_organization_id(&self, project_id: Uuid) -> Option { let url = self.url(&format!("/api/remote/projects/{}", project_id)); let response = tokio::time::timeout( std::time::Duration::from_millis(2000), self.client.get(&url).send(), ) .await .ok()? .ok()?; if !response.status().is_success() { return None; } let api_response: ApiResponseEnvelope = response.json().await.ok()?; let project = api_response.data?; Some(project.organization_id) } } ================================================ FILE: crates/mcp/src/task_server/tools/context.rs ================================================ use rmcp::{ErrorData, model::CallToolResult, tool, tool_router}; use super::McpServer; #[tool_router(router = context_tools_router, vis = "pub")] impl McpServer { #[tool( description = "Return project, issue, workspace, and orchestrator-session metadata for the current MCP context." )] async fn get_context(&self) -> Result { let context = self.context.as_ref().expect("VK context should exist"); McpServer::success(context) } } ================================================ FILE: crates/mcp/src/task_server/tools/issue_assignees.rs ================================================ use api_types::{ CreateIssueAssigneeRequest, IssueAssignee, ListIssueAssigneesResponse, MutationResponse, }; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListIssueAssigneesRequest { #[schemars(description = "Issue ID to list assignees for")] issue_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct IssueAssigneeSummary { #[schemars(description = "Issue assignee ID")] id: String, #[schemars(description = "Issue ID")] issue_id: String, #[schemars(description = "User ID")] user_id: String, #[schemars(description = "Assignment timestamp")] assigned_at: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListIssueAssigneesResponse { issue_id: String, issue_assignees: Vec, count: usize, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpAssignIssueRequest { #[schemars(description = "Issue ID to assign")] issue_id: Uuid, #[schemars(description = "User ID to assign to the issue")] user_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpAssignIssueResponse { issue_assignee_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpUnassignIssueRequest { #[schemars(description = "Issue assignee ID to remove")] issue_assignee_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpUnassignIssueResponse { success: bool, issue_assignee_id: String, } #[tool_router(router = issue_assignees_tools_router, vis = "pub")] impl McpServer { #[tool(description = "List assignees for an issue.")] async fn list_issue_assignees( &self, Parameters(McpListIssueAssigneesRequest { issue_id }): Parameters< McpListIssueAssigneesRequest, >, ) -> Result { let url = self.url(&format!( "/api/remote/issue-assignees?issue_id={}", issue_id )); let response: ListIssueAssigneesResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let assignees = response .issue_assignees .into_iter() .map(|assignee| IssueAssigneeSummary { id: assignee.id.to_string(), issue_id: assignee.issue_id.to_string(), user_id: assignee.user_id.to_string(), assigned_at: assignee.assigned_at.to_rfc3339(), }) .collect::>(); McpServer::success(&McpListIssueAssigneesResponse { issue_id: issue_id.to_string(), count: assignees.len(), issue_assignees: assignees, }) } #[tool(description = "Assign a user to an issue.")] async fn assign_issue( &self, Parameters(McpAssignIssueRequest { issue_id, user_id }): Parameters, ) -> Result { let payload = CreateIssueAssigneeRequest { id: None, issue_id, user_id, }; let url = self.url("/api/remote/issue-assignees"); let response: MutationResponse = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&McpAssignIssueResponse { issue_assignee_id: response.data.id.to_string(), }) } #[tool(description = "Remove an assignee from an issue using issue_assignee_id.")] async fn unassign_issue( &self, Parameters(McpUnassignIssueRequest { issue_assignee_id }): Parameters< McpUnassignIssueRequest, >, ) -> Result { let url = self.url(&format!( "/api/remote/issue-assignees/{}", issue_assignee_id )); if let Err(e) = self.send_empty_json(self.client.delete(&url)).await { return Ok(e); } McpServer::success(&McpUnassignIssueResponse { success: true, issue_assignee_id: issue_assignee_id.to_string(), }) } } ================================================ FILE: crates/mcp/src/task_server/tools/issue_relationships.rs ================================================ use api_types::{ CreateIssueRelationshipRequest, IssueRelationship, IssueRelationshipType, MutationResponse, }; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpCreateIssueRelationshipRequest { #[schemars(description = "The source issue ID")] issue_id: Uuid, #[schemars(description = "The related issue ID")] related_issue_id: Uuid, #[schemars(description = "Relationship type: 'blocking', 'related', or 'has_duplicate'")] relationship_type: IssueRelationshipType, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpCreateIssueRelationshipResponse { relationship_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpDeleteIssueRelationshipRequest { #[schemars( description = "The relationship ID to delete (from get_issue or create_issue_relationship)" )] relationship_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpDeleteIssueRelationshipResponse { success: bool, deleted_relationship_id: String, } #[tool_router(router = issue_relationships_tools_router, vis = "pub")] impl McpServer { #[tool( description = "Create a relationship between two issues. Types: 'blocking', 'related', 'has_duplicate'." )] async fn create_issue_relationship( &self, Parameters(McpCreateIssueRelationshipRequest { issue_id, related_issue_id, relationship_type, }): Parameters, ) -> Result { let payload = CreateIssueRelationshipRequest { id: None, issue_id, related_issue_id, relationship_type, }; let url = self.url("/api/remote/issue-relationships"); let response: MutationResponse = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&McpCreateIssueRelationshipResponse { relationship_id: response.data.id.to_string(), }) } #[tool(description = "Delete a relationship between two issues.")] async fn delete_issue_relationship( &self, Parameters(McpDeleteIssueRelationshipRequest { relationship_id }): Parameters< McpDeleteIssueRelationshipRequest, >, ) -> Result { let url = self.url(&format!( "/api/remote/issue-relationships/{}", relationship_id )); if let Err(e) = self.send_empty_json(self.client.delete(&url)).await { return Ok(e); } McpServer::success(&McpDeleteIssueRelationshipResponse { success: true, deleted_relationship_id: relationship_id.to_string(), }) } } ================================================ FILE: crates/mcp/src/task_server/tools/issue_tags.rs ================================================ use api_types::{ CreateIssueTagRequest, IssueTag, ListIssueTagsResponse, ListTagsResponse, MutationResponse, }; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListTagsRequest { #[schemars( description = "The project ID to list tags from. Optional if running inside a workspace linked to a remote project." )] project_id: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct TagSummary { #[schemars(description = "Tag ID")] id: String, #[schemars(description = "Project ID")] project_id: String, #[schemars(description = "Tag name")] name: String, #[schemars(description = "Tag color value")] color: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListTagsResponse { project_id: String, tags: Vec, count: usize, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListIssueTagsRequest { #[schemars(description = "Issue ID to list tags for")] issue_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct IssueTagSummary { #[schemars(description = "Issue-tag relation ID")] id: String, #[schemars(description = "Issue ID")] issue_id: String, #[schemars(description = "Tag ID")] tag_id: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListIssueTagsResponse { issue_id: String, issue_tags: Vec, count: usize, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpAddIssueTagRequest { #[schemars(description = "Issue ID to attach the tag to")] issue_id: Uuid, #[schemars(description = "Tag ID to attach")] tag_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpAddIssueTagResponse { issue_tag_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpRemoveIssueTagRequest { #[schemars(description = "Issue-tag relation ID to remove")] issue_tag_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpRemoveIssueTagResponse { success: bool, issue_tag_id: String, } #[tool_router(router = issue_tags_tools_router, vis = "pub")] impl McpServer { #[tool( description = "List tags for a project. `project_id` is optional if running inside a workspace linked to a remote project." )] async fn list_tags( &self, Parameters(McpListTagsRequest { project_id }): Parameters, ) -> Result { let project_id = match self.resolve_project_id(project_id) { Ok(id) => id, Err(e) => return Ok(e), }; let url = self.url(&format!("/api/remote/tags?project_id={}", project_id)); let response: ListTagsResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let tags = response .tags .into_iter() .map(|tag| TagSummary { id: tag.id.to_string(), project_id: tag.project_id.to_string(), name: tag.name, color: tag.color, }) .collect::>(); McpServer::success(&McpListTagsResponse { project_id: project_id.to_string(), count: tags.len(), tags, }) } #[tool(description = "List tags attached to an issue.")] async fn list_issue_tags( &self, Parameters(McpListIssueTagsRequest { issue_id }): Parameters, ) -> Result { let url = self.url(&format!("/api/remote/issue-tags?issue_id={}", issue_id)); let response: ListIssueTagsResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let issue_tags = response .issue_tags .into_iter() .map(|issue_tag| IssueTagSummary { id: issue_tag.id.to_string(), issue_id: issue_tag.issue_id.to_string(), tag_id: issue_tag.tag_id.to_string(), }) .collect::>(); McpServer::success(&McpListIssueTagsResponse { issue_id: issue_id.to_string(), count: issue_tags.len(), issue_tags, }) } #[tool(description = "Attach a tag to an issue.")] async fn add_issue_tag( &self, Parameters(McpAddIssueTagRequest { issue_id, tag_id }): Parameters, ) -> Result { let payload = CreateIssueTagRequest { id: None, issue_id, tag_id, }; let url = self.url("/api/remote/issue-tags"); let response: MutationResponse = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&McpAddIssueTagResponse { issue_tag_id: response.data.id.to_string(), }) } #[tool(description = "Remove a tag from an issue using issue_tag_id.")] async fn remove_issue_tag( &self, Parameters(McpRemoveIssueTagRequest { issue_tag_id }): Parameters, ) -> Result { let url = self.url(&format!("/api/remote/issue-tags/{}", issue_tag_id)); if let Err(e) = self.send_empty_json(self.client.delete(&url)).await { return Ok(e); } McpServer::success(&McpRemoveIssueTagResponse { success: true, issue_tag_id: issue_tag_id.to_string(), }) } } ================================================ FILE: crates/mcp/src/task_server/tools/mod.rs ================================================ use std::str::FromStr; use api_types::{Issue, ListProjectStatusesResponse, ProjectStatus}; use db::models::{execution_process::ExecutionProcessStatus, tag::Tag}; use executors::executors::BaseCodingAgent; use regex::Regex; use rmcp::{ ErrorData, model::{CallToolResult, Content}, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use uuid::Uuid; use super::{ApiResponseEnvelope, McpMode, McpServer}; mod context; mod issue_assignees; mod issue_relationships; mod issue_tags; mod organizations; mod remote_issues; mod remote_projects; mod repos; mod sessions; mod task_attempts; mod workspaces; impl McpServer { pub fn global_mode_router() -> rmcp::handler::server::tool::ToolRouter { Self::context_tools_router() + Self::workspaces_tools_router() + Self::organizations_tools_router() + Self::repos_tools_router() + Self::remote_projects_tools_router() + Self::remote_issues_tools_router() + Self::issue_assignees_tools_router() + Self::issue_tags_tools_router() + Self::issue_relationships_tools_router() + Self::task_attempts_tools_router() + Self::session_tools_router() } pub fn orchestrator_mode_router() -> rmcp::handler::server::tool::ToolRouter { let mut router = Self::context_tools_router() + Self::workspaces_tools_router() + Self::session_tools_router(); router.remove_route::<(), ()>("list_workspaces"); router.remove_route::<(), ()>("delete_workspace"); router } } impl McpServer { fn orchestrator_session_id(&self) -> Option { self.context .as_ref() .and_then(|ctx| ctx.orchestrator_session_id) } fn scoped_workspace_id(&self) -> Option { self.context.as_ref().map(|ctx| ctx.workspace_id) } fn success(data: &T) -> Result { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(data) .unwrap_or_else(|_| "Failed to serialize response".to_string()), )])) } fn err_value(v: serde_json::Value) -> Result { Ok(CallToolResult::error(vec![Content::text( serde_json::to_string_pretty(&v) .unwrap_or_else(|_| "Failed to serialize error".to_string()), )])) } fn err>(msg: S, details: Option) -> Result { let mut v = serde_json::json!({"success": false, "error": msg.into()}); if let Some(d) = details { v["details"] = serde_json::json!(d.into()); }; Self::err_value(v) } async fn send_json( &self, rb: reqwest::RequestBuilder, ) -> Result { let resp = rb .send() .await .map_err(|e| Self::err("Failed to connect to VK API", Some(&e.to_string())).unwrap())?; if !resp.status().is_success() { let status = resp.status(); return Err( Self::err(format!("VK API returned error status: {}", status), None).unwrap(), ); } let api_response = resp.json::>().await.map_err(|e| { Self::err("Failed to parse VK API response", Some(&e.to_string())).unwrap() })?; if !api_response.success { let msg = api_response.message.as_deref().unwrap_or("Unknown error"); return Err(Self::err("VK API returned error", Some(msg)).unwrap()); } api_response .data .ok_or_else(|| Self::err("VK API response missing data field", None).unwrap()) } async fn send_empty_json(&self, rb: reqwest::RequestBuilder) -> Result<(), CallToolResult> { let resp = rb .send() .await .map_err(|e| Self::err("Failed to connect to VK API", Some(&e.to_string())).unwrap())?; if !resp.status().is_success() { let status = resp.status(); return Err( Self::err(format!("VK API returned error status: {}", status), None).unwrap(), ); } #[derive(Deserialize)] struct EmptyApiResponse { success: bool, message: Option, } let api_response = resp.json::().await.map_err(|e| { Self::err("Failed to parse VK API response", Some(&e.to_string())).unwrap() })?; if !api_response.success { let msg = api_response.message.as_deref().unwrap_or("Unknown error"); return Err(Self::err("VK API returned error", Some(msg)).unwrap()); } Ok(()) } fn resolve_workspace_id(&self, explicit: Option) -> Result { if let Some(id) = explicit { return Ok(id); } if let Some(workspace_id) = self.scoped_workspace_id() { return Ok(workspace_id); } Err(Self::err( "workspace_id is required (not available from current MCP context)", None::<&str>, ) .unwrap()) } fn scope_allows_workspace(&self, workspace_id: Uuid) -> Result<(), CallToolResult> { if matches!(self.mode(), McpMode::Orchestrator) && let Some(scoped_workspace_id) = self.scoped_workspace_id() && scoped_workspace_id != workspace_id { return Err(Self::err( "Operation is outside the configured workspace scope".to_string(), Some(format!( "requested workspace_id={}, configured workspace_id={}", workspace_id, scoped_workspace_id )), ) .unwrap()); } Ok(()) } // Expands @tagname references in text by replacing them with tag content. async fn expand_tags(&self, text: &str) -> String { let tag_pattern = match Regex::new(r"@([^\s@]+)") { Ok(re) => re, Err(_) => return text.to_string(), }; let tag_names: Vec = tag_pattern .captures_iter(text) .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) .collect::>() .into_iter() .collect(); if tag_names.is_empty() { return text.to_string(); } let url = self.url("/api/tags"); let tags: Vec = match self.client.get(&url).send().await { Ok(resp) if resp.status().is_success() => { match resp.json::>>().await { Ok(envelope) if envelope.success => envelope.data.unwrap_or_default(), _ => return text.to_string(), } } _ => return text.to_string(), }; let tag_map: std::collections::HashMap<&str, &str> = tags .iter() .map(|t| (t.tag_name.as_str(), t.content.as_str())) .collect(); let result = tag_pattern.replace_all(text, |caps: ®ex::Captures| { let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or(""); match tag_map.get(tag_name) { Some(content) => (*content).to_string(), None => caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string(), } }); result.into_owned() } // Resolves a project_id from an explicit parameter or falls back to context. fn resolve_project_id(&self, explicit: Option) -> Result { if let Some(id) = explicit { return Ok(id); } if let Some(ctx) = &self.context && let Some(id) = ctx.project_id { return Ok(id); } Err(Self::err( "project_id is required (not available from workspace context)", None::<&str>, ) .unwrap()) } // Resolves an organization_id from an explicit parameter or falls back to context. fn resolve_organization_id(&self, explicit: Option) -> Result { if let Some(id) = explicit { return Ok(id); } if let Some(ctx) = &self.context && let Some(id) = ctx.organization_id { return Ok(id); } Err(Self::err( "organization_id is required (not available from workspace context)", None::<&str>, ) .unwrap()) } // Fetches project statuses for a project. async fn fetch_project_statuses( &self, project_id: Uuid, ) -> Result, CallToolResult> { let url = self.url(&format!( "/api/remote/project-statuses?project_id={}", project_id )); let response: ListProjectStatusesResponse = self.send_json(self.client.get(&url)).await?; Ok(response.project_statuses) } // Resolves a status name to status_id. async fn resolve_status_id( &self, project_id: Uuid, status_name: &str, ) -> Result { let statuses = self.fetch_project_statuses(project_id).await?; statuses .iter() .find(|s| s.name.eq_ignore_ascii_case(status_name)) .map(|s| s.id) .ok_or_else(|| { let available: Vec<&str> = statuses.iter().map(|s| s.name.as_str()).collect(); Self::err( format!( "Unknown status '{}'. Available statuses: {:?}", status_name, available ), None::, ) .unwrap() }) } // Gets the default status_id for a project (first non-hidden status by sort_order). async fn default_status_id(&self, project_id: Uuid) -> Result { let statuses = self.fetch_project_statuses(project_id).await?; statuses .iter() .filter(|s| !s.hidden) .min_by_key(|s| s.sort_order) .map(|s| s.id) .ok_or_else(|| { Self::err("No visible statuses found for project", None::<&str>).unwrap() }) } // Resolves a status_id to its display name. Falls back to UUID string if lookup fails. async fn resolve_status_name(&self, project_id: Uuid, status_id: Uuid) -> String { match self.fetch_project_statuses(project_id).await { Ok(statuses) => statuses .iter() .find(|s| s.id == status_id) .map(|s| s.name.clone()) .unwrap_or_else(|| status_id.to_string()), Err(_) => status_id.to_string(), } } // Links a workspace to a remote issue by fetching issue.project_id and calling link endpoint. async fn link_workspace_to_issue( &self, workspace_id: Uuid, issue_id: Uuid, ) -> Result<(), CallToolResult> { let issue_url = self.url(&format!("/api/remote/issues/{}", issue_id)); let issue: Issue = self.send_json(self.client.get(&issue_url)).await?; let link_url = self.url(&format!("/api/workspaces/{}/links", workspace_id)); let link_payload = serde_json::json!({ "project_id": issue.project_id, "issue_id": issue_id, }); self.send_empty_json(self.client.post(&link_url).json(&link_payload)) .await } fn parse_executor_agent(executor: &str) -> Result { let normalized = executor.replace('-', "_").to_ascii_uppercase(); BaseCodingAgent::from_str(&normalized).map_err(|_| { Self::err(format!("Unknown executor '{executor}'."), None::).unwrap() }) } fn normalize_executor_name(executor: Option<&str>) -> Result { let Some(executor) = executor.map(str::trim).filter(|value| !value.is_empty()) else { return Ok("CODEX".to_string()); }; Self::parse_executor_agent(executor) .map(|agent| agent.to_string()) .map_err(|_| { Self::err( format!("Unknown executor '{}' configured for session", executor), None::, ) .unwrap() }) } fn execution_process_status_label(status: &ExecutionProcessStatus) -> &'static str { match status { ExecutionProcessStatus::Running => "running", ExecutionProcessStatus::Completed => "completed", ExecutionProcessStatus::Failed => "failed", ExecutionProcessStatus::Killed => "killed", } } } #[cfg(test)] mod tests { use std::{collections::BTreeSet, sync::Once}; use rmcp::handler::server::tool::ToolRouter; use uuid::Uuid; use super::McpServer; use crate::task_server::{McpContext, McpMode, McpRepoContext}; static RUSTLS_PROVIDER: Once = Once::new(); fn install_rustls_provider() { RUSTLS_PROVIDER.call_once(|| { rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); }); } fn tool_names(router: rmcp::handler::server::tool::ToolRouter) -> BTreeSet { router .list_all() .into_iter() .map(|tool| tool.name.to_string()) .collect() } #[test] fn orchestrator_mode_exposes_only_scoped_workflow_tools() { let actual = tool_names(McpServer::orchestrator_mode_router()); let expected = BTreeSet::from([ "create_session".to_string(), "get_context".to_string(), "get_execution".to_string(), "list_sessions".to_string(), "run_session_prompt".to_string(), "update_session".to_string(), "update_workspace".to_string(), ]); assert_eq!(actual, expected); } #[test] fn global_mode_keeps_workspace_admin_and_discovery_tools() { let actual = tool_names(McpServer::global_mode_router()); assert!(actual.contains("list_workspaces")); assert!(actual.contains("delete_workspace")); assert!(!actual.contains("output_markdown")); } #[test] fn orchestrator_session_id_is_resolved_from_context() { install_rustls_provider(); let session_id = Uuid::new_v4(); let workspace_id = Uuid::new_v4(); let server = McpServer { client: reqwest::Client::new(), base_url: "http://127.0.0.1:3000".to_string(), tool_router: ToolRouter::default(), context: Some(McpContext { organization_id: None, project_id: None, issue_id: None, orchestrator_session_id: Some(session_id), workspace_id, workspace_branch: "main".to_string(), workspace_repos: vec![McpRepoContext { repo_id: Uuid::new_v4(), repo_name: "repo".to_string(), target_branch: "main".to_string(), }], }), mode: McpMode::Global, }; assert_eq!(server.orchestrator_session_id(), Some(session_id)); assert_eq!(server.resolve_workspace_id(None).unwrap(), workspace_id); } #[test] fn orchestrator_scope_requires_context_when_missing() { install_rustls_provider(); let server = McpServer { client: reqwest::Client::new(), base_url: "http://127.0.0.1:3000".to_string(), tool_router: ToolRouter::default(), context: None, mode: McpMode::Orchestrator, }; assert_eq!(server.orchestrator_session_id(), None); assert!(server.resolve_workspace_id(None).is_err()); assert!(server.scope_allows_workspace(Uuid::new_v4()).is_ok()); } #[test] fn global_context_omits_orchestrator_session_id_from_serialized_output() { install_rustls_provider(); let context = McpContext { organization_id: None, project_id: None, issue_id: None, orchestrator_session_id: None, workspace_id: Uuid::new_v4(), workspace_branch: "main".to_string(), workspace_repos: vec![], }; let serialized = serde_json::to_value(&context).expect("context should serialize"); assert!(serialized.get("orchestrator_session_id").is_none()); } } ================================================ FILE: crates/mcp/src/task_server/tools/organizations.rs ================================================ use api_types::{ListMembersResponse, ListOrganizationsResponse}; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Serialize, schemars::JsonSchema)] struct OrganizationSummary { #[schemars(description = "The unique identifier of the organization")] id: String, #[schemars(description = "The name of the organization")] name: String, #[schemars(description = "The slug of the organization")] slug: String, #[schemars(description = "Whether this is a personal organization")] is_personal: bool, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListOrganizationsResponse { organizations: Vec, count: usize, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListOrgMembersRequest { #[schemars( description = "The organization ID to list members from. Optional if running inside a workspace linked to a remote organization." )] organization_id: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct OrganizationMemberSummary { #[schemars(description = "The user ID of the organization member")] user_id: String, #[schemars(description = "The member role in the organization")] role: String, #[schemars(description = "When the member joined the organization")] joined_at: String, #[schemars(description = "Optional first name")] first_name: Option, #[schemars(description = "Optional last name")] last_name: Option, #[schemars(description = "Optional username")] username: Option, #[schemars(description = "Optional email")] email: Option, #[schemars(description = "Optional avatar URL")] avatar_url: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListOrgMembersResponse { organization_id: String, members: Vec, count: usize, } #[tool_router(router = organizations_tools_router, vis = "pub")] impl McpServer { #[tool(description = "List all the available organizations")] async fn list_organizations(&self) -> Result { let url = self.url("/api/organizations"); let response: ListOrganizationsResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let org_summaries: Vec = response .organizations .into_iter() .map(|o| OrganizationSummary { id: o.id.to_string(), name: o.name, slug: o.slug, is_personal: o.is_personal, }) .collect(); McpServer::success(&McpListOrganizationsResponse { count: org_summaries.len(), organizations: org_summaries, }) } #[tool( description = "List members of an organization. `organization_id` is optional if running inside a workspace linked to a remote organization." )] async fn list_org_members( &self, Parameters(McpListOrgMembersRequest { organization_id }): Parameters< McpListOrgMembersRequest, >, ) -> Result { let organization_id = match self.resolve_organization_id(organization_id) { Ok(id) => id, Err(e) => return Ok(e), }; let url = self.url(&format!("/api/organizations/{}/members", organization_id)); let response: ListMembersResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let members: Vec = response .members .into_iter() .map(|member| OrganizationMemberSummary { user_id: member.user_id.to_string(), role: format!("{:?}", member.role).to_uppercase(), joined_at: member.joined_at.to_rfc3339(), first_name: member.first_name, last_name: member.last_name, username: member.username, email: member.email, avatar_url: member.avatar_url, }) .collect(); McpServer::success(&McpListOrgMembersResponse { organization_id: organization_id.to_string(), count: members.len(), members, }) } } ================================================ FILE: crates/mcp/src/task_server/tools/remote_issues.rs ================================================ use std::collections::HashMap; use api_types::{ CreateIssueRequest, Issue, IssuePriority, IssueRelationshipType, IssueSortField, ListIssueRelationshipsResponse, ListIssueTagsResponse, ListIssuesResponse, ListPullRequestsResponse, ListTagsResponse, MutationResponse, PullRequestStatus, SearchIssuesRequest, SortDirection, UpdateIssueRequest, }; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpCreateIssueRequest { #[schemars( description = "The ID of the project to create the issue in. Optional if running inside a workspace linked to a remote project." )] project_id: Option, #[schemars(description = "The title of the issue")] title: String, #[schemars(description = "Optional description of the issue")] description: Option, #[schemars( description = "Optional priority of the issue. Allowed values: 'urgent', 'high', 'medium', 'low'." )] priority: Option, #[schemars(description = "Optional parent issue ID to create a subissue")] parent_issue_id: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpCreateIssueResponse { issue_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListIssuesRequest { #[schemars( description = "The ID of the project to list issues from. Optional if running inside a workspace linked to a remote project." )] project_id: Option, #[schemars(description = "Maximum number of issues to return (default: 50)")] limit: Option, #[schemars(description = "Number of results to skip before returning rows (default: 0)")] offset: Option, #[schemars(description = "Filter by status name (case-insensitive)")] status: Option, #[schemars( description = "Filter by priority. Allowed values: 'urgent', 'high', 'medium', 'low'." )] priority: Option, #[schemars(description = "Filter by parent issue ID (subissues of this issue)")] parent_issue_id: Option, #[schemars(description = "Case-insensitive substring match against title and description")] search: Option, #[schemars(description = "Filter by issue simple ID (case-insensitive exact match)")] simple_id: Option, #[schemars(description = "Filter to issues assigned to this user ID")] assignee_user_id: Option, #[schemars(description = "Filter to issues having this tag ID")] tag_id: Option, #[schemars(description = "Filter to issues having a tag with this name (case-insensitive)")] tag_name: Option, #[schemars( description = "Field to sort by. Allowed values: 'sort_order', 'priority', 'created_at', 'updated_at', 'title'. Default: 'sort_order'." )] sort_field: Option, #[schemars(description = "Sort direction. Allowed values: 'asc', 'desc'. Default: 'asc'.")] sort_direction: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct IssueSummary { #[schemars(description = "The unique identifier of the issue")] id: String, #[schemars(description = "The title of the issue")] title: String, #[schemars(description = "The human-readable issue simple ID")] simple_id: String, #[schemars(description = "Current status of the issue")] status: String, #[schemars(description = "Current priority of the issue")] priority: Option, #[schemars(description = "Parent issue ID if this is a subissue")] parent_issue_id: Option, #[schemars(description = "When the issue was created")] created_at: String, #[schemars(description = "When the issue was last updated")] updated_at: String, #[schemars(description = "Number of pull requests linked to this issue")] pull_request_count: usize, #[schemars(description = "URL of the most recent pull request, if any")] latest_pr_url: Option, #[schemars( description = "Status of the most recent pull request: 'open', 'merged', or 'closed'" )] latest_pr_status: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct PullRequestSummary { #[schemars(description = "PR number")] number: i32, #[schemars(description = "URL of the pull request")] url: String, #[schemars(description = "Status of the pull request: 'open', 'merged', or 'closed'")] status: PullRequestStatus, #[schemars(description = "When the PR was merged, if applicable")] merged_at: Option, #[schemars(description = "Target branch for the PR")] target_branch_name: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpTagSummary { #[schemars(description = "The tag ID")] id: String, #[schemars(description = "The tag name")] name: String, #[schemars(description = "The tag color")] color: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpRelationshipSummary { #[schemars(description = "The relationship ID (use this to delete)")] id: String, #[schemars(description = "The related issue ID")] related_issue_id: String, #[schemars(description = "The related issue's simple ID (e.g. 'PROJ-42')")] related_simple_id: String, #[schemars(description = "Relationship type: blocking, related, or has_duplicate")] relationship_type: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpSubIssueSummary { #[schemars(description = "The sub-issue ID")] id: String, #[schemars(description = "Short human-readable identifier (e.g. 'PROJ-43')")] simple_id: String, #[schemars(description = "The sub-issue title")] title: String, #[schemars(description = "Current status of the sub-issue")] status: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct IssueDetails { #[schemars(description = "The unique identifier of the issue")] id: String, #[schemars(description = "The title of the issue")] title: String, #[schemars(description = "The human-readable issue simple ID")] simple_id: String, #[schemars(description = "Optional description of the issue")] description: Option, #[schemars(description = "Current status of the issue")] status: String, #[schemars(description = "The status ID (UUID)")] status_id: String, #[schemars(description = "Current priority of the issue")] priority: Option, #[schemars(description = "Parent issue ID if this is a subissue")] parent_issue_id: Option, #[schemars(description = "Optional planned start date")] start_date: Option, #[schemars(description = "Optional planned target date")] target_date: Option, #[schemars(description = "Optional completion date")] completed_at: Option, #[schemars(description = "When the issue was created")] created_at: String, #[schemars(description = "When the issue was last updated")] updated_at: String, #[schemars(description = "Pull requests linked to this issue")] pull_requests: Vec, #[schemars(description = "Tags attached to this issue")] tags: Vec, #[schemars(description = "Relationships to other issues")] relationships: Vec, #[schemars(description = "Sub-issues under this issue")] sub_issues: Vec, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListIssuesResponse { issues: Vec, total_count: usize, returned_count: usize, limit: usize, offset: usize, project_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpUpdateIssueRequest { #[schemars(description = "The ID of the issue to update")] issue_id: Uuid, #[schemars(description = "New title for the issue")] title: Option, #[schemars(description = "New description for the issue")] description: Option, #[schemars(description = "New status name for the issue (must match a project status name)")] status: Option, #[schemars( description = "New priority for the issue. Allowed values: 'urgent', 'high', 'medium', 'low'." )] priority: Option, #[schemars( description = "Parent issue ID to set this as a subissue. Pass null to un-nest from parent." )] parent_issue_id: Option>, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpUpdateIssueResponse { issue: IssueDetails, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpDeleteIssueRequest { #[schemars(description = "The ID of the issue to delete")] issue_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpDeleteIssueResponse { deleted_issue_id: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpGetIssueRequest { #[schemars(description = "The ID of the issue to retrieve")] issue_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpGetIssueResponse { issue: IssueDetails, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListIssuePrioritiesResponse { priorities: Vec, } #[tool_router(router = remote_issues_tools_router, vis = "pub")] impl McpServer { #[tool( description = "Create a new issue in a project. `project_id` is optional if running inside a workspace linked to a remote project." )] async fn create_issue( &self, Parameters(McpCreateIssueRequest { project_id, title, description, priority, parent_issue_id, }): Parameters, ) -> Result { let project_id = match self.resolve_project_id(project_id) { Ok(id) => id, Err(e) => return Ok(e), }; let expanded_description = match description { Some(desc) => Some(self.expand_tags(&desc).await), None => None, }; let status_id = match self.default_status_id(project_id).await { Ok(id) => id, Err(e) => return Ok(e), }; let priority = match priority { Some(p) => match Self::parse_issue_priority(&p) { Ok(priority) => Some(priority), Err(e) => return Ok(e), }, None => None, }; let payload = CreateIssueRequest { id: None, project_id, status_id, title, description: expanded_description, priority, start_date: None, target_date: None, completed_at: None, sort_order: 0.0, parent_issue_id, parent_issue_sort_order: None, extension_metadata: serde_json::json!({}), }; let url = self.url("/api/remote/issues"); let response: MutationResponse = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&McpCreateIssueResponse { issue_id: response.data.id.to_string(), }) } #[tool( description = "List all the issues in a project. `project_id` is optional if running inside a workspace linked to a remote project." )] async fn list_issues( &self, Parameters(McpListIssuesRequest { project_id, limit, offset, status, priority, parent_issue_id, search, simple_id, assignee_user_id, tag_id, tag_name, sort_field, sort_direction, }): Parameters, ) -> Result { let project_id = match self.resolve_project_id(project_id) { Ok(id) => id, Err(e) => return Ok(e), }; let project_statuses = match self.fetch_project_statuses(project_id).await { Ok(statuses) => Some(statuses), Err(e) => { if status.is_some() { return Ok(e); } None } }; let status_names_by_id = project_statuses.as_ref().map(|statuses| { statuses .iter() .map(|status| (status.id, status.name.clone())) .collect::>() }); let (status_id, status_ids, missing_status_name_match) = match status.as_deref() { Some(status) => match Uuid::parse_str(status) { Ok(status_id) => (Some(status_id), None, false), Err(_) => { let matching_status_ids = project_statuses .as_deref() .map(|statuses| { Self::matching_ids_by_name( statuses .iter() .map(|status| (status.id, status.name.as_str())), status, ) }) .unwrap_or_default(); let missing_status_name_match = matching_status_ids.is_empty(); ( None, (!missing_status_name_match).then_some(matching_status_ids), missing_status_name_match, ) } }, None => (None, None, false), }; let priority = match priority { Some(priority) => match Self::parse_issue_priority(&priority) { Ok(priority) => Some(priority), Err(e) => return Ok(e), }, None => None, }; let sort_field = match Self::parse_issue_sort_field(sort_field.as_deref()) { Ok(value) => Some(value), Err(e) => return Ok(e), }; let sort_direction = match Self::parse_sort_direction(sort_direction.as_deref()) { Ok(value) => Some(value), Err(e) => return Ok(e), }; let matching_tag_ids = match tag_name.as_deref() { Some(tag_name) => match self.find_tag_ids_by_name(project_id, tag_name).await { Ok(tag_ids) => Some(tag_ids), Err(e) => return Ok(e), }, None => None, }; let (tag_id, tag_ids, missing_tag_name_match) = Self::resolve_tag_filters(tag_id, matching_tag_ids); let response = if missing_status_name_match || missing_tag_name_match { ListIssuesResponse { issues: Vec::new(), total_count: 0, limit: limit.unwrap_or(50).max(0) as usize, offset: offset.unwrap_or(0).max(0) as usize, } } else { let query = SearchIssuesRequest { project_id, status_id, status_ids, priority, parent_issue_id, search, simple_id, assignee_user_id, tag_id, tag_ids, sort_field, sort_direction, limit: Some(limit.unwrap_or(50).max(0)), offset: Some(offset.unwrap_or(0).max(0)), }; let url = self.url("/api/remote/issues/search"); match self.send_json(self.client.post(&url).json(&query)).await { Ok(r) => r, Err(e) => return Ok(e), } }; let mut summaries = Vec::with_capacity(response.issues.len()); for issue in &response.issues { let pull_requests = self.fetch_pull_requests(issue.id).await; summaries.push(self.issue_to_summary( issue, status_names_by_id.as_ref(), &pull_requests, )); } McpServer::success(&McpListIssuesResponse { total_count: response.total_count, returned_count: summaries.len(), limit: response.limit, offset: response.offset, issues: summaries, project_id: project_id.to_string(), }) } #[tool( description = "Get detailed information about a specific issue. You can use `list_issues` to find issue IDs. `issue_id` is required." )] async fn get_issue( &self, Parameters(McpGetIssueRequest { issue_id }): Parameters, ) -> Result { let url = self.url(&format!("/api/remote/issues/{}", issue_id)); let issue: Issue = match self.send_json(self.client.get(&url)).await { Ok(i) => i, Err(e) => return Ok(e), }; let pull_requests = self.fetch_pull_requests(issue_id).await; let details = self.issue_to_details(&issue, pull_requests).await; McpServer::success(&McpGetIssueResponse { issue: details }) } #[tool( description = "Update an existing issue's title, description, or status. `issue_id` is required. `title`, `description`, and `status` are optional." )] async fn update_issue( &self, Parameters(McpUpdateIssueRequest { issue_id, title, description, status, priority, parent_issue_id, }): Parameters, ) -> Result { // First get the issue to know its project_id for status resolution let get_url = self.url(&format!("/api/remote/issues/{}", issue_id)); let existing_issue: Issue = match self.send_json(self.client.get(&get_url)).await { Ok(i) => i, Err(e) => return Ok(e), }; // Resolve status name to status_id if provided let status_id = if let Some(ref status_name) = status { match self .resolve_status_id(existing_issue.project_id, status_name) .await { Ok(id) => Some(id), Err(e) => return Ok(e), } } else { None }; // Expand @tagname references in description let expanded_description = match description { Some(desc) => Some(Some(self.expand_tags(&desc).await)), None => None, }; let priority = if let Some(priority) = priority { match Self::parse_issue_priority(&priority) { Ok(parsed) => Some(Some(parsed)), Err(e) => return Ok(e), } } else { None }; let payload = UpdateIssueRequest { status_id, title, description: expanded_description, priority, start_date: None, target_date: None, completed_at: None, sort_order: None, parent_issue_id, parent_issue_sort_order: None, extension_metadata: None, }; let url = self.url(&format!("/api/remote/issues/{}", issue_id)); let response: MutationResponse = match self.send_json(self.client.patch(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; let pull_requests = self.fetch_pull_requests(issue_id).await; let details = self.issue_to_details(&response.data, pull_requests).await; McpServer::success(&McpUpdateIssueResponse { issue: details }) } #[tool(description = "List allowed issue priority values.")] async fn list_issue_priorities(&self) -> Result { McpServer::success(&McpListIssuePrioritiesResponse { priorities: ["urgent", "high", "medium", "low"] .iter() .map(|s| s.to_string()) .collect(), }) } #[tool(description = "Delete an issue. `issue_id` is required.")] async fn delete_issue( &self, Parameters(McpDeleteIssueRequest { issue_id }): Parameters, ) -> Result { let url = self.url(&format!("/api/remote/issues/{}", issue_id)); if let Err(e) = self.send_empty_json(self.client.delete(&url)).await { return Ok(e); } McpServer::success(&McpDeleteIssueResponse { deleted_issue_id: Some(issue_id.to_string()), }) } } impl McpServer { fn parse_issue_sort_field(sort_field: Option<&str>) -> Result { match sort_field.unwrap_or("sort_order").trim().to_ascii_lowercase().as_str() { "sort_order" => Ok(IssueSortField::SortOrder), "priority" => Ok(IssueSortField::Priority), "created_at" => Ok(IssueSortField::CreatedAt), "updated_at" => Ok(IssueSortField::UpdatedAt), "title" => Ok(IssueSortField::Title), other => Err(Self::err( format!( "Unknown sort_field '{}'. Allowed values: ['sort_order', 'priority', 'created_at', 'updated_at', 'title']", other ), None::, ) .unwrap()), } } fn parse_sort_direction(sort_direction: Option<&str>) -> Result { match sort_direction .unwrap_or("asc") .trim() .to_ascii_lowercase() .as_str() { "asc" => Ok(SortDirection::Asc), "desc" => Ok(SortDirection::Desc), other => Err(Self::err( format!( "Unknown sort_direction '{}'. Allowed values: ['asc', 'desc']", other ), None::, ) .unwrap()), } } fn issue_to_summary( &self, issue: &Issue, status_names_by_id: Option<&HashMap>, pull_requests: &ListPullRequestsResponse, ) -> IssueSummary { let status = status_names_by_id .and_then(|status_map| status_map.get(&issue.status_id).cloned()) .unwrap_or_else(|| issue.status_id.to_string()); let latest_pr = pull_requests.pull_requests.first(); IssueSummary { id: issue.id.to_string(), title: issue.title.clone(), simple_id: issue.simple_id.clone(), status, priority: issue .priority .map(Self::issue_priority_label) .map(str::to_string), parent_issue_id: issue.parent_issue_id.map(|id| id.to_string()), created_at: issue.created_at.to_rfc3339(), updated_at: issue.updated_at.to_rfc3339(), pull_request_count: pull_requests.pull_requests.len(), latest_pr_url: latest_pr.map(|pr| pr.url.clone()), latest_pr_status: latest_pr.map(|pr| pr.status), } } async fn issue_to_details( &self, issue: &Issue, pull_requests: ListPullRequestsResponse, ) -> IssueDetails { let status = self .resolve_status_name(issue.project_id, issue.status_id) .await; let tags = self .fetch_issue_tags_resolved(issue.project_id, issue.id) .await; let relationships = self .fetch_issue_relationships_resolved(issue.project_id, issue.id) .await; let sub_issues = self.fetch_sub_issues(issue.project_id, issue.id).await; IssueDetails { id: issue.id.to_string(), title: issue.title.clone(), simple_id: issue.simple_id.clone(), description: issue.description.clone(), status, status_id: issue.status_id.to_string(), priority: issue .priority .map(Self::issue_priority_label) .map(str::to_string), parent_issue_id: issue.parent_issue_id.map(|id| id.to_string()), start_date: issue.start_date.map(|date| date.to_rfc3339()), target_date: issue.target_date.map(|date| date.to_rfc3339()), completed_at: issue.completed_at.map(|date| date.to_rfc3339()), created_at: issue.created_at.to_rfc3339(), updated_at: issue.updated_at.to_rfc3339(), pull_requests: pull_requests .pull_requests .into_iter() .map(|pr| PullRequestSummary { number: pr.number, url: pr.url, status: pr.status, merged_at: pr.merged_at.map(|dt| dt.to_rfc3339()), target_branch_name: pr.target_branch_name, }) .collect(), tags, relationships, sub_issues, } } async fn fetch_pull_requests(&self, issue_id: Uuid) -> ListPullRequestsResponse { let url = self.url(&format!("/api/remote/pull-requests?issue_id={}", issue_id)); match self .send_json::(self.client.get(&url)) .await { Ok(response) => response, Err(_) => ListPullRequestsResponse { pull_requests: vec![], }, } } /// Fetches tags for an issue, resolving tag_ids to names via project tags. async fn fetch_issue_tags_resolved( &self, project_id: Uuid, issue_id: Uuid, ) -> Vec { let tags_url = self.url(&format!("/api/remote/tags?project_id={}", project_id)); let project_tags: ListTagsResponse = match self.send_json(self.client.get(&tags_url)).await { Ok(r) => r, Err(_) => return Vec::new(), }; let tag_map: HashMap = project_tags.tags.iter().map(|t| (t.id, t)).collect(); let url = self.url(&format!("/api/remote/issue-tags?issue_id={}", issue_id)); let response: ListIssueTagsResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(_) => return Vec::new(), }; response .issue_tags .iter() .filter_map(|it| { tag_map.get(&it.tag_id).map(|tag| McpTagSummary { id: tag.id.to_string(), name: tag.name.clone(), color: tag.color.clone(), }) }) .collect() } /// Fetches relationships for an issue, resolving related issue simple_ids. async fn fetch_issue_relationships_resolved( &self, project_id: Uuid, issue_id: Uuid, ) -> Vec { let rel_url = self.url(&format!( "/api/remote/issue-relationships?issue_id={}", issue_id )); let response: ListIssueRelationshipsResponse = match self.send_json(self.client.get(&rel_url)).await { Ok(r) => r, Err(_) => return Vec::new(), }; if response.issue_relationships.is_empty() { return Vec::new(); } let issues_url = self.url(&format!("/api/remote/issues?project_id={}", project_id)); let issues_response: api_types::ListIssuesResponse = self .send_json(self.client.get(&issues_url)) .await .unwrap_or(api_types::ListIssuesResponse { issues: Vec::new(), total_count: 0, limit: 0, offset: 0, }); let simple_id_map: HashMap = issues_response .issues .iter() .map(|i| (i.id, i.simple_id.as_str())) .collect(); response .issue_relationships .into_iter() .map(|r| { let related_simple_id = simple_id_map .get(&r.related_issue_id) .unwrap_or(&"") .to_string(); McpRelationshipSummary { id: r.id.to_string(), related_issue_id: r.related_issue_id.to_string(), related_simple_id, relationship_type: match r.relationship_type { IssueRelationshipType::Blocking => "blocking".to_string(), IssueRelationshipType::Related => "related".to_string(), IssueRelationshipType::HasDuplicate => "has_duplicate".to_string(), }, } }) .collect() } /// Fetches sub-issues for a given parent issue. async fn fetch_sub_issues( &self, project_id: Uuid, parent_issue_id: Uuid, ) -> Vec { let url = self.url(&format!("/api/remote/issues?project_id={}", project_id)); let response: api_types::ListIssuesResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(_) => return Vec::new(), }; let status_names = self .fetch_project_statuses(project_id) .await .ok() .map(|statuses| { statuses .into_iter() .map(|s| (s.id, s.name)) .collect::>() }); response .issues .iter() .filter(|i| i.parent_issue_id == Some(parent_issue_id)) .map(|i| { let status = status_names .as_ref() .and_then(|m| m.get(&i.status_id).cloned()) .unwrap_or_else(|| i.status_id.to_string()); McpSubIssueSummary { id: i.id.to_string(), simple_id: i.simple_id.clone(), title: i.title.clone(), status, } }) .collect() } fn parse_issue_priority(priority: &str) -> Result { match priority.trim().to_ascii_lowercase().as_str() { "urgent" => Ok(IssuePriority::Urgent), "high" => Ok(IssuePriority::High), "medium" => Ok(IssuePriority::Medium), "low" => Ok(IssuePriority::Low), _ => Err(Self::err( format!( "Unknown priority '{}'. Allowed values: ['urgent', 'high', 'medium', 'low']", priority ), None::, ) .unwrap()), } } fn issue_priority_label(priority: IssuePriority) -> &'static str { match priority { IssuePriority::Urgent => "urgent", IssuePriority::High => "high", IssuePriority::Medium => "medium", IssuePriority::Low => "low", } } async fn find_tag_ids_by_name( &self, project_id: Uuid, tag_name: &str, ) -> Result, CallToolResult> { let url = self.url(&format!("/api/remote/tags?project_id={}", project_id)); let tags: ListTagsResponse = self.send_json(self.client.get(&url)).await?; Ok(Self::matching_ids_by_name( tags.tags.iter().map(|tag| (tag.id, tag.name.as_str())), tag_name, )) } fn matching_ids_by_name<'a>( items: impl IntoIterator, name: &str, ) -> Vec { items .into_iter() .filter(|(_, item_name)| item_name.eq_ignore_ascii_case(name)) .map(|(id, _)| id) .collect() } fn resolve_tag_filters( tag_id: Option, matching_tag_ids: Option>, ) -> (Option, Option>, bool) { match (tag_id, matching_tag_ids) { (Some(tag_id), Some(matching_tag_ids)) => { if matching_tag_ids.contains(&tag_id) { (Some(tag_id), None, false) } else { (None, None, true) } } (None, Some(matching_tag_ids)) => { let missing_tag_name_match = matching_tag_ids.is_empty(); ( None, (!missing_tag_name_match).then_some(matching_tag_ids), missing_tag_name_match, ) } (Some(tag_id), None) => (Some(tag_id), None, false), (None, None) => (None, None, false), } } } #[cfg(test)] mod tests { use super::*; #[test] fn collects_all_matching_status_ids_case_insensitively() { let first_id = Uuid::new_v4(); let second_id = Uuid::new_v4(); let statuses = [ (first_id, "In Progress"), (second_id, "in progress"), (Uuid::new_v4(), "Todo"), ]; assert_eq!( McpServer::matching_ids_by_name(statuses, "IN PROGRESS"), vec![first_id, second_id] ); } #[test] fn collects_all_matching_tag_ids_case_insensitively() { let first_id = Uuid::new_v4(); let second_id = Uuid::new_v4(); let tags = [ (first_id, "bug"), (second_id, "Bug"), (Uuid::new_v4(), "feature"), ]; assert_eq!( McpServer::matching_ids_by_name(tags, "BUG"), vec![first_id, second_id] ); } #[test] fn resolve_tag_filters_requires_explicit_tag_id_to_match_tag_name() { let tag_id = Uuid::new_v4(); let other_tag_id = Uuid::new_v4(); assert_eq!( McpServer::resolve_tag_filters(Some(tag_id), Some(vec![other_tag_id])), (None, None, true) ); } #[test] fn resolve_tag_filters_preserves_exact_tag_id_intersection() { let tag_id = Uuid::new_v4(); let other_tag_id = Uuid::new_v4(); assert_eq!( McpServer::resolve_tag_filters(Some(tag_id), Some(vec![other_tag_id, tag_id])), (Some(tag_id), None, false) ); } } ================================================ FILE: crates/mcp/src/task_server/tools/remote_projects.rs ================================================ use api_types::ListProjectsResponse; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListProjectsRequest { #[schemars(description = "The ID of the organization to list projects from")] organization_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct ProjectSummary { #[schemars(description = "The unique identifier of the project")] id: String, #[schemars(description = "The name of the project")] name: String, #[schemars(description = "When the project was created")] created_at: String, #[schemars(description = "When the project was last updated")] updated_at: String, } impl ProjectSummary { fn from_remote_project(project: api_types::Project) -> Self { Self { id: project.id.to_string(), name: project.name, created_at: project.created_at.to_rfc3339(), updated_at: project.updated_at.to_rfc3339(), } } } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListProjectsResponse { projects: Vec, count: usize, } #[tool_router(router = remote_projects_tools_router, vis = "pub")] impl McpServer { #[tool(description = "List all the available projects")] async fn list_projects( &self, Parameters(McpListProjectsRequest { organization_id }): Parameters, ) -> Result { let url = self.url(&format!( "/api/remote/projects?organization_id={}", organization_id )); let response: ListProjectsResponse = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; let project_summaries: Vec = response .projects .into_iter() .map(ProjectSummary::from_remote_project) .collect(); McpServer::success(&McpListProjectsResponse { count: project_summaries.len(), projects: project_summaries, }) } } ================================================ FILE: crates/mcp/src/task_server/tools/repos.rs ================================================ use db::models::repo::Repo; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpRepoSummary { #[schemars(description = "The unique identifier of the repository")] id: String, #[schemars(description = "The name of the repository")] name: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct GetRepoRequest { #[schemars(description = "The ID of the repository to retrieve")] repo_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct RepoDetails { #[schemars(description = "The unique identifier of the repository")] id: String, #[schemars(description = "The name of the repository")] name: String, #[schemars(description = "The display name of the repository")] display_name: String, #[schemars(description = "The setup script that runs when initializing a workspace")] setup_script: Option, #[schemars(description = "The cleanup script that runs when tearing down a workspace")] cleanup_script: Option, #[schemars(description = "The dev server script that starts the development server")] dev_server_script: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct UpdateSetupScriptRequest { #[schemars(description = "The ID of the repository to update")] repo_id: Uuid, #[schemars(description = "The new setup script content (use empty string to clear)")] script: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct UpdateCleanupScriptRequest { #[schemars(description = "The ID of the repository to update")] repo_id: Uuid, #[schemars(description = "The new cleanup script content (use empty string to clear)")] script: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct UpdateDevServerScriptRequest { #[schemars(description = "The ID of the repository to update")] repo_id: Uuid, #[schemars(description = "The new dev server script content (use empty string to clear)")] script: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct UpdateRepoScriptResponse { #[schemars(description = "Whether the update was successful")] success: bool, #[schemars(description = "The repository ID that was updated")] repo_id: String, #[schemars(description = "The script field that was updated")] field: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct ListReposResponse { repos: Vec, count: usize, } #[tool_router(router = repos_tools_router, vis = "pub")] impl McpServer { #[tool(description = "List all repositories.")] async fn list_repos(&self) -> Result { let url = self.url("/api/repos"); let repos: Vec = match self.send_json(self.client.get(&url)).await { Ok(rs) => rs, Err(e) => return Ok(e), }; let repo_summaries: Vec = repos .into_iter() .map(|r| McpRepoSummary { id: r.id.to_string(), name: r.name, }) .collect(); let response = ListReposResponse { count: repo_summaries.len(), repos: repo_summaries, }; McpServer::success(&response) } #[tool( description = "Get detailed information about a repository including its scripts. Use `list_repos` to find available repo IDs." )] async fn get_repo( &self, Parameters(GetRepoRequest { repo_id }): Parameters, ) -> Result { let url = self.url(&format!("/api/repos/{}", repo_id)); let repo: Repo = match self.send_json(self.client.get(&url)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&RepoDetails { id: repo.id.to_string(), name: repo.name, display_name: repo.display_name, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, dev_server_script: repo.dev_server_script, }) } #[tool( description = "Update a repository's setup script. The setup script runs when initializing a workspace." )] async fn update_setup_script( &self, Parameters(UpdateSetupScriptRequest { repo_id, script }): Parameters< UpdateSetupScriptRequest, >, ) -> Result { let url = self.url(&format!("/api/repos/{}", repo_id)); let script_value = if script.is_empty() { None } else { Some(script) }; let payload = serde_json::json!({ "setup_script": script_value }); let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&UpdateRepoScriptResponse { success: true, repo_id: repo_id.to_string(), field: "setup_script".to_string(), }) } #[tool( description = "Update a repository's cleanup script. The cleanup script runs when tearing down a workspace." )] async fn update_cleanup_script( &self, Parameters(UpdateCleanupScriptRequest { repo_id, script }): Parameters< UpdateCleanupScriptRequest, >, ) -> Result { let url = self.url(&format!("/api/repos/{}", repo_id)); let script_value = if script.is_empty() { None } else { Some(script) }; let payload = serde_json::json!({ "cleanup_script": script_value }); let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&UpdateRepoScriptResponse { success: true, repo_id: repo_id.to_string(), field: "cleanup_script".to_string(), }) } #[tool( description = "Update a repository's dev server script. The dev server script starts the development server for the repository." )] async fn update_dev_server_script( &self, Parameters(UpdateDevServerScriptRequest { repo_id, script }): Parameters< UpdateDevServerScriptRequest, >, ) -> Result { let url = self.url(&format!("/api/repos/{}", repo_id)); let script_value = if script.is_empty() { None } else { Some(script) }; let payload = serde_json::json!({ "dev_server_script": script_value }); let _repo: Repo = match self.send_json(self.client.put(&url).json(&payload)).await { Ok(r) => r, Err(e) => return Ok(e), }; McpServer::success(&UpdateRepoScriptResponse { success: true, repo_id: repo_id.to_string(), field: "dev_server_script".to_string(), }) } } ================================================ FILE: crates/mcp/src/task_server/tools/sessions.rs ================================================ use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessStatus}, session::Session, }; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct CreateSessionRequest { #[schemars( description = "Workspace ID to create the session in. Optional when running inside a scoped orchestrator MCP." )] workspace_id: Option, #[schemars(description = "Optional executor to pin this session to")] executor: Option, #[schemars(description = "Optional display name for the session")] name: Option, } #[derive(Debug, Serialize)] struct CreateSessionPayload { workspace_id: Uuid, executor: Option, name: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct SessionSummary { #[schemars(description = "Session ID")] id: String, #[schemars(description = "Workspace ID")] workspace_id: String, #[schemars(description = "Session display name (if set)")] name: Option, #[schemars(description = "Session executor (if set)")] executor: Option, #[schemars(description = "Creation timestamp")] created_at: String, #[schemars(description = "Last update timestamp")] updated_at: String, #[schemars(description = "True if this is the orchestrator session for this MCP server")] is_orchestrator_session: bool, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct CreateSessionResponse { session: SessionSummary, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ListSessionsRequest { #[schemars( description = "Workspace ID to inspect. Optional when running inside a scoped orchestrator MCP." )] workspace_id: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct ListSessionsResponse { #[schemars(description = "Workspace ID this result is scoped to")] workspace_id: String, total_count: usize, sessions: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct RunCodingAgentInSessionRequest { #[schemars(description = "Session ID to run the coding agent in")] session_id: Uuid, #[schemars(description = "Prompt for the coding agent")] prompt: String, } #[derive(Debug, Serialize)] struct FollowUpPayload { prompt: String, executor_config: ExecutorConfigPayload, retry_process_id: Option, force_when_dirty: Option, perform_git_reset: Option, } #[derive(Debug, Serialize)] struct ExecutorConfigPayload { executor: String, variant: Option, model_id: Option, agent_id: Option, reasoning_id: Option, permission_policy: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct RunCodingAgentInSessionResponse { session_id: String, execution_id: String, execution: serde_json::Value, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct UpdateSessionRequest { #[schemars(description = "Session ID to update")] session_id: Uuid, #[schemars(description = "Set session display name (empty string clears it)")] name: Option, } #[derive(Debug, Serialize)] struct UpdateSessionPayload { name: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct UpdateSessionResponse { success: bool, session_id: String, name: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct GetExecutionRequest { #[schemars(description = "Execution ID to inspect")] execution_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct GetExecutionResponse { execution_id: String, session_id: String, status: String, is_finished: bool, execution: serde_json::Value, #[schemars(description = "Final assistant message/summary when execution has finished")] final_message: Option, } #[tool_router(router = session_tools_router, vis = "pub")] impl McpServer { #[tool(description = "Create a new session in a workspace.")] async fn create_session( &self, Parameters(CreateSessionRequest { workspace_id, executor, name, }): Parameters, ) -> Result { let workspace_id = match self.resolve_workspace_id(workspace_id) { Ok(id) => id, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(workspace_id) { return Ok(error_result); } let payload = CreateSessionPayload { workspace_id, executor: executor.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }), name: name.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }), }; let url = self.url("/api/sessions"); let session: Session = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; Self::success(&CreateSessionResponse { session: self.session_summary(session), }) } #[tool(description = "List all sessions for a workspace.")] async fn list_sessions( &self, Parameters(ListSessionsRequest { workspace_id }): Parameters, ) -> Result { let workspace_id = match self.resolve_workspace_id(workspace_id) { Ok(id) => id, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(workspace_id) { return Ok(error_result); } let url = self.url(&format!("/api/sessions?workspace_id={workspace_id}")); let sessions: Vec = match self.send_json(self.client.get(&url)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; let sessions = sessions .into_iter() .map(|session| self.session_summary(session)) .collect::>(); Self::success(&ListSessionsResponse { workspace_id: workspace_id.to_string(), total_count: sessions.len(), sessions, }) } #[tool(description = "Update a session's name. `session_id` is required.")] async fn update_session( &self, Parameters(UpdateSessionRequest { session_id, name }): Parameters, ) -> Result { // Verify session exists and check scope let session_url = self.url(&format!("/api/sessions/{session_id}")); let session: Session = match self.send_json(self.client.get(&session_url)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) { return Ok(error_result); } let payload = UpdateSessionPayload { name: name.map(|value| value.trim().to_string()), }; let url = self.url(&format!("/api/sessions/{session_id}")); let updated: Session = match self.send_json(self.client.put(&url).json(&payload)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; Self::success(&UpdateSessionResponse { success: true, session_id: updated.id.to_string(), name: updated.name, }) } #[tool( description = "Run a coding agent turn in an existing session and return immediately with the execution process." )] async fn run_session_prompt( &self, Parameters(RunCodingAgentInSessionRequest { session_id, prompt }): Parameters< RunCodingAgentInSessionRequest, >, ) -> Result { let prompt = prompt.trim(); if prompt.is_empty() { return Self::err("prompt must not be empty", None); } let session_url = self.url(&format!("/api/sessions/{session_id}")); let session: Session = match self.send_json(self.client.get(&session_url)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) { return Ok(error_result); } if self.orchestrator_session_id() == Some(session_id) { return Self::err( "Cannot run coding agent in the orchestrator session".to_string(), Some( "Create or re-use a different session and run the coding agent there." .to_string(), ), ); } let executor_config = match Self::executor_config_payload_for_session(&session) { Ok(config) => config, Err(error_result) => return Ok(error_result), }; let payload = FollowUpPayload { prompt: prompt.to_string(), executor_config, retry_process_id: None, force_when_dirty: None, perform_git_reset: None, }; let url = self.url(&format!("/api/sessions/{session_id}/follow-up")); let execution_process: ExecutionProcess = match self.send_json(self.client.post(&url).json(&payload)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; let execution_id = execution_process.id.to_string(); let execution = match Self::serialize_execution_process(&execution_process) { Ok(value) => value, Err(error_result) => return Ok(error_result), }; Self::success(&RunCodingAgentInSessionResponse { session_id: session_id.to_string(), execution_id, execution, }) } #[tool(description = "Get status for an execution.")] async fn get_execution( &self, Parameters(GetExecutionRequest { execution_id }): Parameters, ) -> Result { let process_url = self.url(&format!("/api/execution-processes/{execution_id}")); let execution_process: ExecutionProcess = match self.send_json(self.client.get(&process_url)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; let session_url = self.url(&format!("/api/sessions/{}", execution_process.session_id)); let session: Session = match self.send_json(self.client.get(&session_url)).await { Ok(value) => value, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(session.workspace_id) { return Ok(error_result); } let is_finished = execution_process.status != ExecutionProcessStatus::Running; let execution_process_value = match Self::serialize_execution_process(&execution_process) { Ok(value) => value, Err(error_result) => return Ok(error_result), }; Self::success(&GetExecutionResponse { execution_id: execution_process.id.to_string(), session_id: execution_process.session_id.to_string(), status: Self::execution_process_status_label(&execution_process.status).to_string(), is_finished, execution: execution_process_value, final_message: None, }) } } impl McpServer { fn executor_config_payload_for_session( session: &Session, ) -> Result { Ok(ExecutorConfigPayload { executor: Self::normalize_executor_name(session.executor.as_deref())?, variant: None, model_id: None, agent_id: None, reasoning_id: None, permission_policy: None, }) } fn session_summary(&self, session: Session) -> SessionSummary { let is_orchestrator_session = self.orchestrator_session_id() == Some(session.id); SessionSummary { id: session.id.to_string(), workspace_id: session.workspace_id.to_string(), name: session.name, executor: session.executor, created_at: session.created_at.to_rfc3339(), updated_at: session.updated_at.to_rfc3339(), is_orchestrator_session, } } fn serialize_execution_process( execution_process: &ExecutionProcess, ) -> Result { serde_json::to_value(execution_process).map_err(|error| { Self::err( "Failed to serialize execution process response".to_string(), Some(error.to_string()), ) .unwrap() }) } } ================================================ FILE: crates/mcp/src/task_server/tools/task_attempts.rs ================================================ use db::models::requests::{ CreateAndStartWorkspaceRequest, CreateAndStartWorkspaceResponse, LinkedIssueInfo, WorkspaceRepoInput, }; use executors::profile::ExecutorConfig; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpWorkspaceRepoInput { #[schemars(description = "The repository ID")] repo_id: Uuid, #[schemars(description = "The branch for this repository")] branch: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct StartWorkspaceRequest { #[schemars(description = "Name for the workspace")] name: String, #[schemars( description = "Optional prompt for the first workspace session. If omitted/empty, the linked issue title/description is used." )] prompt: Option, #[schemars( description = "The coding agent executor to run ('CLAUDE_CODE', 'AMP', 'GEMINI', 'CODEX', 'OPENCODE', 'CURSOR_AGENT', 'QWEN_CODE', 'COPILOT', 'DROID')" )] executor: String, #[schemars(description = "Optional executor variant, if needed")] variant: Option, #[schemars(description = "Repository selection for the workspace")] repositories: Vec, #[schemars( description = "Optional issue ID to link the workspace to. When provided, the workspace will be associated with this remote issue." )] issue_id: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct StartWorkspaceResponse { workspace_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct LinkWorkspaceIssueRequest { #[schemars(description = "The workspace ID to link")] workspace_id: Uuid, #[schemars(description = "The issue ID to link the workspace to")] issue_id: Uuid, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct LinkWorkspaceIssueResponse { #[schemars(description = "Whether the linking was successful")] success: bool, #[schemars(description = "The workspace ID that was linked")] workspace_id: String, #[schemars(description = "The issue ID it was linked to")] issue_id: String, } fn build_workspace_prompt_from_issue(issue: &api_types::Issue) -> Option { let title = issue.title.trim(); let description = issue .description .as_deref() .map(str::trim) .filter(|d| !d.is_empty()) .unwrap_or_default(); if title.is_empty() && description.is_empty() { return None; } if description.is_empty() { return Some(title.to_string()); } if title.is_empty() { return Some(description.to_string()); } Some(format!("{title}\n\n{description}")) } #[tool_router(router = task_attempts_tools_router, vis = "pub")] impl McpServer { #[tool(description = "Create a new workspace and start its first session.")] async fn start_workspace( &self, Parameters(StartWorkspaceRequest { name, prompt, executor, variant, repositories, issue_id, }): Parameters, ) -> Result { if repositories.is_empty() { return Self::err("At least one repository must be specified.", None::<&str>); } let executor_trimmed = executor.trim(); if executor_trimmed.is_empty() { return Self::err("Executor must not be empty.", None::<&str>); } let prompt = prompt.and_then(|prompt| { let trimmed = prompt.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); let base_executor = match Self::parse_executor_agent(executor_trimmed) { Ok(exec) => exec, Err(_) => { return Self::err( format!("Unknown executor '{executor_trimmed}'."), None::, ); } }; let variant = variant.and_then(|v| { let trimmed = v.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); let workspace_repos: Vec = repositories .into_iter() .map(|r| WorkspaceRepoInput { repo_id: r.repo_id, target_branch: r.branch, }) .collect(); let (linked_issue, issue_prompt) = if let Some(issue_id) = issue_id { let issue_url = self.url(&format!("/api/remote/issues/{issue_id}")); let issue: api_types::Issue = match self.send_json(self.client.get(&issue_url)).await { Ok(issue) => issue, Err(e) => return Ok(e), }; ( Some(LinkedIssueInfo { remote_project_id: issue.project_id, issue_id, }), build_workspace_prompt_from_issue(&issue), ) } else { (None, None) }; let workspace_prompt = match prompt.or(issue_prompt) { Some(prompt) => prompt, None => { return Self::err( "Provide `prompt`, or `issue_id` that has a non-empty title/description.", None::<&str>, ); } }; let create_and_start_payload = CreateAndStartWorkspaceRequest { name: Some(name.clone()), repos: workspace_repos, linked_issue, executor_config: ExecutorConfig { executor: base_executor, variant, model_id: None, agent_id: None, reasoning_id: None, permission_policy: None, }, prompt: workspace_prompt, attachment_ids: None, }; let create_and_start_url = self.url("/api/workspaces/start"); let create_and_start_response: CreateAndStartWorkspaceResponse = match self .send_json( self.client .post(&create_and_start_url) .json(&create_and_start_payload), ) .await { Ok(response) => response, Err(e) => return Ok(e), }; // Link workspace to remote issue if issue_id is provided if let Some(issue_id) = issue_id && let Err(e) = self .link_workspace_to_issue(create_and_start_response.workspace.id, issue_id) .await { return Ok(e); } let response = StartWorkspaceResponse { workspace_id: create_and_start_response.workspace.id.to_string(), }; McpServer::success(&response) } #[tool( description = "Link an existing workspace to a remote issue. This associates the workspace with the issue for tracking." )] async fn link_workspace_issue( &self, Parameters(LinkWorkspaceIssueRequest { workspace_id, issue_id, }): Parameters, ) -> Result { if let Err(e) = self.link_workspace_to_issue(workspace_id, issue_id).await { return Ok(e); } McpServer::success(&LinkWorkspaceIssueResponse { success: true, workspace_id: workspace_id.to_string(), issue_id: issue_id.to_string(), }) } } ================================================ FILE: crates/mcp/src/task_server/tools/workspaces.rs ================================================ use db::models::{requests::UpdateWorkspace, workspace::Workspace}; use rmcp::{ ErrorData, handler::server::tool::Parameters, model::CallToolResult, schemars, tool, tool_router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::McpServer; #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpListWorkspacesRequest { #[schemars(description = "Filter by archived state")] archived: Option, #[schemars(description = "Filter by pinned state")] pinned: Option, #[schemars(description = "Filter by branch name (exact match, case-insensitive)")] branch: Option, #[schemars(description = "Case-insensitive substring match against workspace name")] name_search: Option, #[schemars(description = "Maximum number of workspaces to return (default: 50)")] limit: Option, #[schemars(description = "Number of results to skip before returning rows (default: 0)")] offset: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct WorkspaceSummary { #[schemars(description = "Workspace ID")] id: String, #[schemars(description = "Workspace branch")] branch: String, #[schemars(description = "Whether the workspace is archived")] archived: bool, #[schemars(description = "Whether the workspace is pinned")] pinned: bool, #[schemars(description = "Optional workspace display name")] name: Option, #[schemars(description = "Creation timestamp")] created_at: String, #[schemars(description = "Last update timestamp")] updated_at: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpListWorkspacesResponse { workspaces: Vec, total_count: usize, returned_count: usize, limit: usize, offset: usize, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpUpdateWorkspaceRequest { #[schemars( description = "Workspace ID to update. Optional if running inside that workspace context." )] workspace_id: Option, #[schemars(description = "Set archived state")] archived: Option, #[schemars(description = "Set pinned state")] pinned: Option, #[schemars(description = "Set workspace display name (empty string clears it)")] name: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpUpdateWorkspaceResponse { success: bool, workspace_id: String, archived: bool, pinned: bool, name: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct McpDeleteWorkspaceRequest { #[schemars( description = "Workspace ID to delete. Optional if running inside that workspace context." )] workspace_id: Option, #[schemars( description = "Also delete linked remote workspace when available (default: false)" )] delete_remote: Option, #[schemars(description = "Also delete workspace branches from repos (default: false)")] delete_branches: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] struct McpDeleteWorkspaceResponse { success: bool, workspace_id: String, delete_remote: bool, delete_branches: bool, } #[tool_router(router = workspaces_tools_router, vis = "pub")] impl McpServer { #[tool(description = "List local workspaces with optional filters and pagination.")] async fn list_workspaces( &self, Parameters(McpListWorkspacesRequest { archived, pinned, branch, name_search, limit, offset, }): Parameters, ) -> Result { let url = self.url("/api/workspaces"); let mut workspaces: Vec = match self.send_json(self.client.get(&url)).await { Ok(ws) => ws, Err(e) => return Ok(e), }; if let Some(archived_filter) = archived { workspaces.retain(|w| w.archived == archived_filter); } if let Some(pinned_filter) = pinned { workspaces.retain(|w| w.pinned == pinned_filter); } if let Some(branch_filter) = branch.as_deref() { workspaces.retain(|w| w.branch.eq_ignore_ascii_case(branch_filter)); } if let Some(name_search) = name_search.as_deref() { let needle = name_search.to_ascii_lowercase(); workspaces.retain(|w| { w.name .as_deref() .map(|name| name.to_ascii_lowercase().contains(&needle)) .unwrap_or(false) }); } // Keep ordering deterministic after filtering. workspaces.sort_by(|a, b| b.created_at.cmp(&a.created_at)); let total_count = workspaces.len(); let offset = offset.unwrap_or(0).max(0) as usize; let limit = limit.unwrap_or(50).max(0) as usize; let workspace_summaries = workspaces .into_iter() .skip(offset) .take(limit) .map(|workspace| WorkspaceSummary { id: workspace.id.to_string(), branch: workspace.branch, archived: workspace.archived, pinned: workspace.pinned, name: workspace.name, created_at: workspace.created_at.to_rfc3339(), updated_at: workspace.updated_at.to_rfc3339(), }) .collect::>(); McpServer::success(&McpListWorkspacesResponse { returned_count: workspace_summaries.len(), total_count, limit, offset, workspaces: workspace_summaries, }) } #[tool( description = "Update a workspace's archived, pinned, or name fields. `workspace_id` is optional if running inside that workspace context." )] async fn update_workspace( &self, Parameters(McpUpdateWorkspaceRequest { workspace_id, archived, pinned, name, }): Parameters, ) -> Result { let workspace_id = match self.resolve_workspace_id(workspace_id) { Ok(id) => id, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(workspace_id) { return Ok(error_result); } let url = self.url(&format!("/api/workspaces/{}", workspace_id)); let payload = UpdateWorkspace { archived, pinned, name, }; let updated: Workspace = match self.send_json(self.client.put(&url).json(&payload)).await { Ok(ws) => ws, Err(e) => return Ok(e), }; McpServer::success(&McpUpdateWorkspaceResponse { success: true, workspace_id: updated.id.to_string(), archived: updated.archived, pinned: updated.pinned, name: updated.name, }) } #[tool( description = "Delete a local workspace. `workspace_id` is optional if running inside that workspace context." )] async fn delete_workspace( &self, Parameters(McpDeleteWorkspaceRequest { workspace_id, delete_remote, delete_branches, }): Parameters, ) -> Result { let workspace_id = match self.resolve_workspace_id(workspace_id) { Ok(id) => id, Err(error_result) => return Ok(error_result), }; if let Err(error_result) = self.scope_allows_workspace(workspace_id) { return Ok(error_result); } let delete_remote = delete_remote.unwrap_or(false); let delete_branches = delete_branches.unwrap_or(false); let url = self.url(&format!("/api/workspaces/{}", workspace_id)); if let Err(e) = self .send_empty_json(self.client.delete(&url).query(&[ ("delete_remote", delete_remote), ("delete_branches", delete_branches), ])) .await { return Ok(e); } McpServer::success(&McpDeleteWorkspaceResponse { success: true, workspace_id: workspace_id.to_string(), delete_remote, delete_branches, }) } } ================================================ FILE: crates/relay-control/Cargo.toml ================================================ [package] name = "relay-control" version = "0.1.33" edition = "2024" [dependencies] tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } base64 = "0.22" ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } rand = "0.8" uuid = { version = "1.0", features = ["v4"] } ================================================ FILE: crates/relay-control/src/lib.rs ================================================ pub mod signing; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; /// Controls the lifecycle of the relay tunnel connection. /// /// Start/stop can be called from login/logout handlers to dynamically /// manage the relay without restarting the server. pub struct RelayControl { /// Token used to cancel the current relay connection shutdown: RwLock>, } impl Default for RelayControl { fn default() -> Self { Self::new() } } impl RelayControl { pub fn new() -> Self { Self { shutdown: RwLock::new(None), } } /// Create a new cancellation token for a relay session. /// Cancels any previously running session first. pub async fn reset(&self) -> CancellationToken { let mut guard = self.shutdown.write().await; if let Some(old) = guard.take() { old.cancel(); } let token = CancellationToken::new(); *guard = Some(token.clone()); token } /// Cancel the current relay session if one is running. pub async fn stop(&self) { let mut guard = self.shutdown.write().await; if let Some(token) = guard.take() { token.cancel(); } } } ================================================ FILE: crates/relay-control/src/signing.rs ================================================ use std::{ collections::HashMap, fs, io, path::Path, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand::rngs::OsRng; use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockWriteGuard}; use uuid::Uuid; struct RelaySigningSession { browser_public_key: VerifyingKey, created_at: Instant, last_used_at: Instant, seen_nonces: HashMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RelaySignatureValidationError { TimestampOutOfDrift, MissingSigningSession, InvalidNonce, ReplayNonce, InvalidSignature, } impl RelaySignatureValidationError { pub fn as_str(self) -> &'static str { match self { Self::TimestampOutOfDrift => "timestamp outside drift window", Self::MissingSigningSession => "missing or expired signing session", Self::InvalidNonce => "invalid nonce", Self::ReplayNonce => "replayed nonce", Self::InvalidSignature => "invalid signature", } } } const RELAY_SIGNATURE_MAX_TIMESTAMP_DRIFT_SECS: i64 = 30; const RELAY_SIGNING_SESSION_TTL: Duration = Duration::from_secs(60 * 60); const RELAY_SIGNING_SESSION_IDLE_TTL: Duration = Duration::from_secs(15 * 60); const RELAY_NONCE_TTL: Duration = Duration::from_secs(2 * 60); #[derive(Clone)] pub struct RelaySigningService { sessions: Arc>>, server_signing_key: Arc, } impl RelaySigningService { pub fn new(server_signing_key: SigningKey) -> Self { Self { sessions: Arc::new(RwLock::new(HashMap::new())), server_signing_key: Arc::new(server_signing_key), } } pub fn load_or_generate(key_path: &Path) -> io::Result { let key = if let Ok(bytes) = fs::read(key_path) { let arr: [u8; 32] = bytes.try_into().map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, "server signing key file has invalid length (expected 32 bytes)", ) })?; SigningKey::from_bytes(&arr) } else { let key = SigningKey::generate(&mut OsRng); if let Some(parent) = key_path.parent() { fs::create_dir_all(parent)?; } let tmp = key_path.with_extension("tmp"); fs::write(&tmp, key.to_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?; } fs::rename(&tmp, key_path)?; key }; Ok(Self::new(key)) } pub fn server_public_key(&self) -> VerifyingKey { self.server_signing_key.verifying_key() } pub async fn create_session(&self, browser_public_key: VerifyingKey) -> Uuid { let signing_session_id = Uuid::new_v4(); let now = Instant::now(); let mut sessions = self.sessions.write().await; sessions.insert( signing_session_id, RelaySigningSession { browser_public_key, created_at: now, last_used_at: now, seen_nonces: HashMap::new(), }, ); signing_session_id } pub async fn verify_message( &self, signing_session_id: Uuid, timestamp: i64, nonce: &str, message: &[u8], signature_b64: &str, ) -> Result<(), RelaySignatureValidationError> { if nonce.trim().is_empty() || nonce.len() > 128 { return Err(RelaySignatureValidationError::InvalidNonce); } validate_timestamp(timestamp)?; let signature = parse_signature_b64(signature_b64)?; let mut session = self.get_valid_session(signing_session_id).await?; session .seen_nonces .retain(|_, seen_at| Instant::now().duration_since(*seen_at) <= RELAY_NONCE_TTL); if session.seen_nonces.contains_key(nonce) { return Err(RelaySignatureValidationError::ReplayNonce); } session .browser_public_key .verify(message, &signature) .map_err(|_| RelaySignatureValidationError::InvalidSignature)?; session .seen_nonces .insert(nonce.to_string(), Instant::now()); session.last_used_at = Instant::now(); Ok(()) } pub async fn sign_message( &self, signing_session_id: Uuid, message: &[u8], ) -> Result { let mut session = self.get_valid_session(signing_session_id).await?; session.last_used_at = Instant::now(); let signature = self.server_signing_key.sign(message); Ok(BASE64_STANDARD.encode(signature.to_bytes())) } pub async fn verify_signature( &self, signing_session_id: Uuid, message: &[u8], signature_b64: &str, ) -> Result<(), RelaySignatureValidationError> { let signature = parse_signature_b64(signature_b64)?; let mut session = self.get_valid_session(signing_session_id).await?; session .browser_public_key .verify(message, &signature) .map_err(|_| RelaySignatureValidationError::InvalidSignature)?; session.last_used_at = Instant::now(); Ok(()) } async fn get_valid_session( &self, signing_session_id: Uuid, ) -> Result, RelaySignatureValidationError> { let mut sessions = self.sessions.write().await; let now = Instant::now(); sessions.retain(|_, session| { now.duration_since(session.created_at) <= RELAY_SIGNING_SESSION_TTL && now.duration_since(session.last_used_at) <= RELAY_SIGNING_SESSION_IDLE_TTL }); RwLockWriteGuard::try_map(sessions, |sessions| sessions.get_mut(&signing_session_id)) .map_err(|_| RelaySignatureValidationError::MissingSigningSession) } } fn validate_timestamp(timestamp: i64) -> Result<(), RelaySignatureValidationError> { let now_secs = i64::try_from( SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RelaySignatureValidationError::TimestampOutOfDrift)? .as_secs(), ) .map_err(|_| RelaySignatureValidationError::TimestampOutOfDrift)?; let drift_secs = now_secs.saturating_sub(timestamp).abs(); if drift_secs > RELAY_SIGNATURE_MAX_TIMESTAMP_DRIFT_SECS { return Err(RelaySignatureValidationError::TimestampOutOfDrift); } Ok(()) } fn parse_signature_b64(signature_b64: &str) -> Result { let sig_bytes = BASE64_STANDARD .decode(signature_b64) .map_err(|_| RelaySignatureValidationError::InvalidSignature)?; Signature::from_slice(&sig_bytes).map_err(|_| RelaySignatureValidationError::InvalidSignature) } ================================================ FILE: crates/relay-tunnel/.sqlx/query-13462773c343a0812783b914d7a09b6e7148d20be4c2a5c92fa5860e1bc5bd36.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO relay_browser_sessions (host_id, user_id, auth_session_id)\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n host_id AS \"host_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n auth_session_id AS \"auth_session_id!: Uuid\",\n created_at,\n last_used_at,\n revoked_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "host_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "auth_session_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "last_used_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "revoked_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true ] }, "hash": "13462773c343a0812783b914d7a09b6e7148d20be4c2a5c92fa5860e1bc5bd36" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-25045af6947be74c0a4f1784670904bd488d1cbafe997a2d8abef620d2e5497f.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO hosts (\n owner_user_id,\n shared_with_organization_id,\n machine_id,\n name,\n status,\n agent_version\n )\n VALUES ($1, NULL, $2, $3, 'offline', $4)\n ON CONFLICT (owner_user_id, machine_id) DO UPDATE\n SET name = EXCLUDED.name,\n agent_version = COALESCE(EXCLUDED.agent_version, hosts.agent_version),\n updated_at = NOW()\n RETURNING id AS \"id!: Uuid\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text" ] }, "nullable": [ false ] }, "hash": "25045af6947be74c0a4f1784670904bd488d1cbafe997a2d8abef620d2e5497f" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS (\n SELECT 1\n FROM hosts h\n LEFT JOIN organization_member_metadata om\n ON om.organization_id = h.shared_with_organization_id\n AND om.user_id = $2\n WHERE h.id = $1\n AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\n ) AS \"allowed!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "allowed!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-2b81f9454b626be80c21761b0fd1e7a83b71bb53a4ababf212d4fb13636119ae.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE relay_browser_sessions\n SET last_used_at = date_trunc('day', NOW())\n WHERE id = $1\n AND (\n last_used_at IS NULL\n OR last_used_at < date_trunc('day', NOW())\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "2b81f9454b626be80c21761b0fd1e7a83b71bb53a4ababf212d4fb13636119ae" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET revoked_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-5ba9186639e75711df9218209ad88f91f54f9643ecf5f53af1e7bfc583727a7c.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE relay_sessions\n SET state = 'expired',\n ended_at = COALESCE(ended_at, NOW())\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "5ba9186639e75711df9218209ad88f91f54f9643ecf5f53af1e7bfc583727a7c" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-6aef9ee49b4bc1d9a23c0322e7733bee239e31380cb4cf8274cb60427b492299.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n host_id AS \"host_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n auth_session_id AS \"auth_session_id!: Uuid\",\n created_at,\n last_used_at,\n revoked_at\n FROM relay_browser_sessions\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "host_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "auth_session_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "last_used_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "revoked_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true ] }, "hash": "6aef9ee49b4bc1d9a23c0322e7733bee239e31380cb4cf8274cb60427b492299" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-6d64fbb63cd05c4cd9bcc3d07cbc26b165f0d0ffbc4df75391c6b205fe0abd78.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE relay_browser_sessions\n SET revoked_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "6d64fbb63cd05c4cd9bcc3d07cbc26b165f0d0ffbc4df75391c6b205fe0abd78" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET last_used_at = date_trunc('day', NOW())\n WHERE id = $1\n AND (\n last_used_at IS NULL\n OR last_used_at < date_trunc('day', NOW())\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-7b010dece6caaeb04e8f033c869982b97f20c60903a92f4d45634f990ffbfce3.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT status FROM hosts WHERE id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "status", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "7b010dece6caaeb04e8f033c869982b97f20c60903a92f4d45634f990ffbfce3" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-80dfc0d5bbc6db412ea0fda24a5184c3ce20064826779571cab1d9e32459c4cb.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n host_id AS \"host_id!: Uuid\",\n request_user_id AS \"request_user_id!: Uuid\",\n state,\n created_at,\n expires_at,\n claimed_at,\n ended_at\n FROM relay_sessions\n WHERE id = $1 AND request_user_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "host_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "request_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "state", "type_info": "Text" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "claimed_at", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "ended_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true ] }, "hash": "80dfc0d5bbc6db412ea0fda24a5184c3ce20064826779571cab1d9e32459c4cb" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-8b865d8fca4a3d7a8f7edf6671aed582164f93f973448143883d6d2fb461caf6.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO relay_auth_codes (code_hash, host_id, relay_cookie_value, expires_at)\n VALUES ($1, $2, $3, $4)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text", "Uuid", "Text", "Timestamptz" ] }, "nullable": [] }, "hash": "8b865d8fca4a3d7a8f7edf6671aed582164f93f973448143883d6d2fb461caf6" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n user_id AS \"user_id!: Uuid\",\n created_at AS \"created_at!\",\n last_used_at AS \"last_used_at?\",\n revoked_at AS \"revoked_at?\",\n refresh_token_id AS \"refresh_token_id?\",\n refresh_token_issued_at AS \"refresh_token_issued_at?\"\n FROM auth_sessions\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "last_used_at?", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "revoked_at?", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "refresh_token_id?", "type_info": "Uuid" }, { "ordinal": 6, "name": "refresh_token_issued_at?", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, true ] }, "hash": "9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-9c2e6a8fc112e4e2980e4dc3f1dae1ea7da376119b0f06aafbc74c7a471f17ad.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE hosts\n SET status = 'online',\n last_seen_at = NOW(),\n agent_version = COALESCE($2, agent_version),\n updated_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [] }, "hash": "9c2e6a8fc112e4e2980e4dc3f1dae1ea7da376119b0f06aafbc74c7a471f17ad" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM users\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email!", "type_info": "Text" }, { "ordinal": 2, "name": "first_name?", "type_info": "Text" }, { "ordinal": 3, "name": "last_name?", "type_info": "Text" }, { "ordinal": 4, "name": "username?", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, true, false, false ] }, "hash": "b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-e84a068d6a2ad1458cf6f45c2f2dde8511355f29677cebfd15783fccd095a131.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE relay_sessions\n SET state = 'active',\n claimed_at = COALESCE(claimed_at, NOW())\n WHERE id = $1\n RETURNING\n id AS \"id!: Uuid\",\n host_id AS \"host_id!: Uuid\",\n request_user_id AS \"request_user_id!: Uuid\",\n state,\n created_at,\n expires_at,\n claimed_at,\n ended_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "host_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "request_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "state", "type_info": "Text" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "claimed_at", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "ended_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true ] }, "hash": "e84a068d6a2ad1458cf6f45c2f2dde8511355f29677cebfd15783fccd095a131" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-f31c0531cb5c099bc0c6b193852d3e3d0be6cfe1104dd792651d9c5434483ef0.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE relay_auth_codes\n SET consumed_at = NOW()\n WHERE code_hash = $1\n AND host_id = $2\n AND consumed_at IS NULL\n AND expires_at > NOW()\n RETURNING relay_cookie_value\n ", "describe": { "columns": [ { "ordinal": 0, "name": "relay_cookie_value", "type_info": "Text" } ], "parameters": { "Left": [ "Text", "Uuid" ] }, "nullable": [ false ] }, "hash": "f31c0531cb5c099bc0c6b193852d3e3d0be6cfe1104dd792651d9c5434483ef0" } ================================================ FILE: crates/relay-tunnel/.sqlx/query-f6d727f8d7baa7b92464ccccccd113fbe7c69a69d91dc6f54c6052eaa65ce868.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE hosts\n SET status = 'offline',\n updated_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "f6d727f8d7baa7b92464ccccccd113fbe7c69a69d91dc6f54c6052eaa65ce868" } ================================================ FILE: crates/relay-tunnel/Cargo.toml ================================================ [package] name = "relay-tunnel" version = "0.1.5" edition = "2024" publish = false [features] default = [] server = [ "dep:sqlx", "dep:jsonwebtoken", "dep:secrecy", "dep:sha2", "dep:base64", "dep:chrono", "dep:axum-extra", "dep:api-types", "dep:uuid", "dep:tower-http", "dep:tracing-subscriber", "dep:serde", "dep:thiserror", ] [[bin]] name = "relay-server" path = "src/bin/relay_server.rs" required-features = ["server"] [dependencies] anyhow = "1.0" axum = { version = "0.8.4", features = ["macros", "multipart", "ws"] } bytes = "1" futures = "0.3" futures-util = "0.3" http = "1" hyper = { version = "1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } tokio = { version = "1.0", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["io"] } tokio-yamux = "0.3.17" tracing = "0.1.43" # Optional deps for the relay-server binary api-types = { path = "../api-types", optional = true } axum-extra = { version = "0.10.3", features = ["typed-header"], optional = true } base64 = { version = "0.22", optional = true } chrono = { version = "0.4", features = ["serde"], optional = true } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"], optional = true } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } secrecy = { version = "0.10.3", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } sha2 = { version = "0.10", optional = true } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono", "macros"], optional = true } thiserror = { version = "2.0.12", optional = true } tower-http = { version = "0.5", features = ["cors", "request-id", "trace", "fs", "validate-request"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"], optional = true } uuid = { version = "1", features = ["serde", "v4"], optional = true } [workspace] ================================================ FILE: crates/relay-tunnel/Dockerfile ================================================ # syntax=docker/dockerfile:1.6 FROM rust:1.93-slim-bookworm AS builder ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse ENV CARGO_TARGET_DIR=/app/target RUN apt-get update \ && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY rust-toolchain.toml ./ RUN cargo --version >/dev/null COPY Cargo.toml Cargo.lock ./ # Copy workspace member manifests so Cargo can resolve workspace-shared deps # without invalidating the build on every unrelated source change. COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml COPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml COPY crates/db/Cargo.toml crates/db/Cargo.toml COPY crates/executors/Cargo.toml crates/executors/Cargo.toml COPY crates/services/Cargo.toml crates/services/Cargo.toml COPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml COPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml COPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml COPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml COPY crates/utils/Cargo.toml crates/utils/Cargo.toml COPY crates/git/Cargo.toml crates/git/Cargo.toml COPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml COPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml COPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml COPY crates/review/Cargo.toml crates/review/Cargo.toml COPY crates/api-types crates/api-types COPY crates/relay-tunnel crates/relay-tunnel RUN mkdir -p /app/bin RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \ --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \ --mount=type=cache,id=relay-target,target=/app/target \ cargo build --locked --release --manifest-path crates/relay-tunnel/Cargo.toml --features server \ && cp /app/target/release/relay-server /app/bin/relay-server FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates libssl3 wget \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --create-home --uid 10001 appuser COPY --from=builder /app/bin/relay-server /usr/local/bin/relay-server USER appuser ENV RELAY_LISTEN_ADDR=0.0.0.0:8082 \ RUST_LOG=info EXPOSE 8082 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["wget","--spider","-q","http://127.0.0.1:8082/health"] ENTRYPOINT ["/usr/local/bin/relay-server"] ================================================ FILE: crates/relay-tunnel/src/bin/relay_server.rs ================================================ use std::sync::Arc; use relay_tunnel::server_bin::{ auth::JwtService, config::RelayServerConfig, db, routes, state::RelayAppState, }; use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialise tracing tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .with(fmt::layer()) .init(); // Force rustls crypto provider (same as remote) let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let config = RelayServerConfig::from_env()?; tracing::info!( listen_addr = %config.listen_addr, "Starting relay server" ); let pool = db::create_pool(&config.database_url).await?; tracing::debug!("Database pool created"); let jwt = Arc::new(JwtService::new(config.jwt_secret.clone())); let state = RelayAppState::new(pool, config.clone(), jwt); let router = routes::build_router(state); let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; tracing::info!("Relay server listening on {}", config.listen_addr); axum::serve(listener, router).await?; Ok(()) } ================================================ FILE: crates/relay-tunnel/src/client.rs ================================================ use std::convert::Infallible; use anyhow::Context as _; use axum::body::Body; use futures_util::StreamExt; use http::StatusCode; use hyper::{ Request, Response, body::Incoming, client::conn::http1 as client_http1, server::conn::http1 as server_http1, service::service_fn, upgrade, }; use hyper_util::rt::TokioIo; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::{self, client::IntoClientRequest}; use tokio_util::sync::CancellationToken; use tokio_yamux::{Config as YamuxConfig, Session}; use crate::{ tls::ws_connector, ws_io::{WsIoReadMessage, WsMessageStreamIo}, }; pub struct RelayClientConfig { pub ws_url: String, pub bearer_token: String, pub local_addr: String, pub shutdown: CancellationToken, } /// Connects the relay client control channel and starts handling inbound streams. /// /// Returns when shutdown is requested or when the control channel disconnects/errors. pub async fn start_relay_client(config: RelayClientConfig) -> anyhow::Result<()> { let mut request = config .ws_url .clone() .into_client_request() .context("Failed to build WS request")?; request.headers_mut().insert( "Authorization", format!("Bearer {}", config.bearer_token) .parse() .context("Invalid auth header")?, ); let (ws_stream, _response) = tokio_tungstenite::connect_async_tls_with_config(request, None, false, ws_connector()) .await .context("Failed to connect relay control channel")?; let ws_io = WsMessageStreamIo::new(ws_stream, read_client_message, write_client_message); let mut session = Session::new_client(ws_io, YamuxConfig::default()); let mut control = session.control(); tracing::debug!("Relay control channel connected"); let shutdown = config.shutdown; let local_addr = config.local_addr; loop { tokio::select! { _ = shutdown.cancelled() => { control.close().await; return Ok(()); } inbound = session.next() => { let stream = inbound .ok_or_else(|| anyhow::anyhow!("Relay control channel closed"))? .map_err(|e| anyhow::anyhow!("Relay yamux session error: {e}"))?; let local_addr = local_addr.clone(); tokio::spawn(async move { if let Err(error) = handle_inbound_stream(stream, local_addr).await { tracing::warn!(?error, "Relay stream handling failed"); } }); } } } } async fn handle_inbound_stream( stream: tokio_yamux::StreamHandle, local_addr: String, ) -> anyhow::Result<()> { let io = TokioIo::new(stream); server_http1::Builder::new() .serve_connection( io, service_fn(move |request: Request| { proxy_to_local(request, local_addr.clone()) }), ) .with_upgrades() .await .context("Yamux stream server connection failed") } async fn proxy_to_local( mut request: Request, local_addr: String, ) -> Result, Infallible> { request .headers_mut() .insert("x-vk-relayed", http::HeaderValue::from_static("1")); // TODO: fix dev servers let local_stream = match TcpStream::connect(local_addr.as_str()).await { Ok(stream) => stream, Err(error) => { tracing::warn!( ?error, "Failed to connect to local server for relay request" ); return Ok(simple_response( StatusCode::BAD_GATEWAY, "Failed to connect to local server", )); } }; let (mut sender, connection) = match client_http1::Builder::new() .handshake(TokioIo::new(local_stream)) .await { Ok(value) => value, Err(error) => { tracing::warn!(?error, "Failed to create local proxy HTTP connection"); return Ok(simple_response( StatusCode::BAD_GATEWAY, "Failed to initialize local proxy connection", )); } }; tokio::spawn(async move { if let Err(error) = connection.with_upgrades().await { tracing::debug!(?error, "Local proxy connection closed"); } }); let request_upgrade = upgrade::on(&mut request); let mut response = match sender.send_request(request).await { Ok(response) => response, Err(error) => { tracing::warn!(?error, "Local proxy request failed"); return Ok(simple_response( StatusCode::BAD_GATEWAY, "Local proxy request failed", )); } }; if response.status() == StatusCode::SWITCHING_PROTOCOLS { let response_upgrade = upgrade::on(&mut response); tokio::spawn(async move { let mut from_remote = TokioIo::new(request_upgrade.await?); let mut to_local = TokioIo::new(response_upgrade.await?); tokio::io::copy_bidirectional(&mut from_remote, &mut to_local).await?; Ok::<_, anyhow::Error>(()) }); } let (parts, body) = response.into_parts(); Ok(Response::from_parts(parts, Body::new(body))) } fn simple_response(status: StatusCode, body: &'static str) -> Response { Response::builder() .status(status) .body(Body::from(body)) .unwrap_or_else(|_| Response::new(Body::from(body))) } fn read_client_message(message: tungstenite::Message) -> WsIoReadMessage { match message { tungstenite::Message::Binary(data) => WsIoReadMessage::Data(data.to_vec()), tungstenite::Message::Text(text) => WsIoReadMessage::Data(text.as_bytes().to_vec()), tungstenite::Message::Close(_) => WsIoReadMessage::Eof, _ => WsIoReadMessage::Skip, } } fn write_client_message(bytes: Vec) -> tungstenite::Message { tungstenite::Message::Binary(bytes) } ================================================ FILE: crates/relay-tunnel/src/lib.rs ================================================ mod tls; mod ws_io; pub mod client; pub mod server; #[cfg(feature = "server")] pub mod server_bin; ================================================ FILE: crates/relay-tunnel/src/server.rs ================================================ use std::{future::Future, sync::Arc}; use axum::{ body::Body, extract::{ Request, ws::{Message as AxumWsMessage, WebSocket}, }, http::{StatusCode, Uri}, response::{IntoResponse, Response}, }; use futures_util::StreamExt; use hyper::{client::conn::http1 as client_http1, upgrade}; use hyper_util::rt::TokioIo; use tokio::sync::Mutex; use tokio_yamux::{Config as YamuxConfig, Control, Session}; use crate::ws_io::{WsIoReadMessage, WsMessageStreamIo}; pub type SharedControl = Arc>; /// Runs the server-side control channel over an upgraded WebSocket. /// /// The provided callback is invoked once, after yamux is initialized, with a /// shared control handle that can be used to proxy requests over new streams. pub async fn run_control_channel(socket: WebSocket, on_connected: F) -> anyhow::Result<()> where F: FnOnce(SharedControl) -> Fut, Fut: Future, { let ws_io = WsMessageStreamIo::new(socket, read_server_message, write_server_message); let mut session = Session::new_server(ws_io, YamuxConfig::default()); let control = Arc::new(Mutex::new(session.control())); on_connected(control).await; while let Some(stream_result) = session.next().await { match stream_result { Ok(_stream) => { // The client side does not currently open server-initiated streams. } Err(error) => { return Err(anyhow::anyhow!("relay session error: {error}")); } } } Ok(()) } /// Proxies one HTTP request over a new yamux stream using the shared control. pub async fn proxy_request_over_control( control: &Mutex, request: Request, strip_prefix: &str, ) -> Response { let stream = { let mut control = control.lock().await; match control.open_stream().await { Ok(stream) => stream, Err(error) => { tracing::warn!(?error, "failed to open relay stream"); return (StatusCode::BAD_GATEWAY, "Relay connection lost").into_response(); } } }; let (mut parts, body) = request.into_parts(); let path = normalized_relay_path(&parts.uri, strip_prefix); parts.uri = match Uri::builder().path_and_query(path).build() { Ok(uri) => uri, Err(error) => { tracing::warn!(?error, "failed to build relay proxy URI"); return (StatusCode::BAD_REQUEST, "Invalid request URI").into_response(); } }; let mut outbound = axum::http::Request::from_parts(parts, body); let request_upgrade = upgrade::on(&mut outbound); let (mut sender, connection) = match client_http1::Builder::new() .handshake(TokioIo::new(stream)) .await { Ok(value) => value, Err(error) => { tracing::warn!(?error, "failed to initialize relay stream proxy connection"); return (StatusCode::BAD_GATEWAY, "Relay connection failed").into_response(); } }; tokio::spawn(async move { if let Err(error) = connection.with_upgrades().await { tracing::debug!(?error, "relay stream connection closed"); } }); let mut response = match sender.send_request(outbound).await { Ok(response) => response, Err(error) => { tracing::warn!(?error, "relay proxy request failed"); return (StatusCode::BAD_GATEWAY, "Relay request failed").into_response(); } }; if response.status() == StatusCode::SWITCHING_PROTOCOLS { let response_upgrade = upgrade::on(&mut response); tokio::spawn(async move { let Ok(from_client) = request_upgrade.await else { return; }; let Ok(to_local) = response_upgrade.await else { return; }; let mut from_client = TokioIo::new(from_client); let mut to_local = TokioIo::new(to_local); let _ = tokio::io::copy_bidirectional(&mut from_client, &mut to_local).await; }); } let (parts, body) = response.into_parts(); Response::from_parts(parts, Body::new(body)) } fn normalized_relay_path(uri: &axum::http::Uri, strip_prefix: &str) -> String { let raw_path = uri.path(); let path = raw_path.strip_prefix(strip_prefix).unwrap_or(raw_path); let path = if path.is_empty() { "/" } else { path }; let query = uri.query().map(|q| format!("?{q}")).unwrap_or_default(); format!("{path}{query}") } fn read_server_message(message: AxumWsMessage) -> WsIoReadMessage { match message { AxumWsMessage::Binary(data) => WsIoReadMessage::Data(data.to_vec()), AxumWsMessage::Text(text) => WsIoReadMessage::Data(text.as_bytes().to_vec()), AxumWsMessage::Close(_) => WsIoReadMessage::Eof, _ => WsIoReadMessage::Skip, } } fn write_server_message(bytes: Vec) -> AxumWsMessage { AxumWsMessage::Binary(bytes.into()) } ================================================ FILE: crates/relay-tunnel/src/server_bin/auth.rs ================================================ use std::{collections::HashSet, sync::Arc}; use api_types::User; use axum::{ body::Body, extract::State, http::{Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer}; use chrono::{DateTime, Utc}; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use tracing::warn; use uuid::Uuid; use super::{ db::{ auth_sessions::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION}, identity_errors::IdentityError, users::UserRepository, }, state::RelayAppState, }; // ── JWT Service (decode-only subset) ────────────────────────────────── #[derive(Debug, Serialize, Deserialize)] struct AccessTokenClaims { pub sub: Uuid, pub session_id: Uuid, pub iat: i64, pub exp: i64, pub aud: String, } #[derive(Debug, Clone)] pub struct AccessTokenDetails { pub user_id: Uuid, pub session_id: Uuid, pub expires_at: DateTime, } #[derive(Debug, thiserror::Error)] pub enum JwtError { #[error("invalid token")] InvalidToken, #[error("invalid jwt secret")] InvalidSecret, #[error(transparent)] Jwt(#[from] jsonwebtoken::errors::Error), } const DEFAULT_JWT_LEEWAY_SECONDS: u64 = 60; #[derive(Clone)] pub struct JwtService { pub secret: Arc, } impl std::fmt::Debug for JwtService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("JwtService") .field("secret", &"[REDACTED]") .finish() } } impl JwtService { pub fn new(secret: SecretString) -> Self { Self { secret: Arc::new(secret), } } pub fn decode_access_token(&self, token: &str) -> Result { if token.trim().is_empty() { return Err(JwtError::InvalidToken); } let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = true; validation.validate_nbf = false; validation.set_audience(&["access"]); validation.required_spec_claims = HashSet::from(["sub".to_string(), "exp".to_string(), "aud".to_string()]); validation.leeway = DEFAULT_JWT_LEEWAY_SECONDS; let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?; let data = decode::(token, &decoding_key, &validation)?; let claims = data.claims; let expires_at = DateTime::from_timestamp(claims.exp, 0).ok_or(JwtError::InvalidToken)?; Ok(AccessTokenDetails { user_id: claims.sub, session_id: claims.session_id, expires_at, }) } } // ── Request Context ─────────────────────────────────────────────────── #[derive(Clone)] pub struct RequestContext { pub user: User, pub session_id: Uuid, #[allow(dead_code)] pub access_token_expires_at: DateTime, } // ── Auth Middleware ─────────────────────────────────────────────────── pub async fn require_session( State(state): State, mut req: Request, next: Next, ) -> Response { let bearer = match req.headers().typed_get::>() { Some(Authorization(token)) => token.token().to_owned(), None => return StatusCode::UNAUTHORIZED.into_response(), }; let ctx = match request_context_from_access_token(&state, &bearer).await { Ok(ctx) => ctx, Err(response) => return response, }; req.extensions_mut().insert(ctx); next.run(req).await } async fn request_context_from_access_token( state: &RelayAppState, access_token: &str, ) -> Result { let identity = match state.jwt.decode_access_token(access_token) { Ok(details) => details, Err(error) => { warn!(?error, "failed to decode access token"); return Err(StatusCode::UNAUTHORIZED.into_response()); } }; let mut ctx = request_context_from_auth_session_id(state, identity.session_id).await?; if ctx.user.id != identity.user_id { warn!( token_user_id = %identity.user_id, session_user_id = %ctx.user.id, session_id = %identity.session_id, "access token user does not match session user" ); return Err(StatusCode::UNAUTHORIZED.into_response()); } ctx.access_token_expires_at = identity.expires_at; Ok(ctx) } pub async fn request_context_from_auth_session_id( state: &RelayAppState, session_id: Uuid, ) -> Result { let pool = &state.pool; let session_repo = AuthSessionRepository::new(pool); let session = match session_repo.get(session_id).await { Ok(session) => session, Err(AuthSessionError::NotFound) => { warn!("session `{}` not found", session_id); return Err(StatusCode::UNAUTHORIZED.into_response()); } Err(AuthSessionError::Database(error)) => { warn!(?error, "failed to load session"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } }; if session.revoked_at.is_some() { warn!("session `{}` rejected (revoked)", session.id); return Err(StatusCode::UNAUTHORIZED.into_response()); } if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION { warn!( "session `{}` expired due to inactivity; revoking", session.id ); if let Err(error) = session_repo.revoke(session.id).await { warn!(?error, "failed to revoke inactive session"); } return Err(StatusCode::UNAUTHORIZED.into_response()); } let user_repo = UserRepository::new(pool); let user = match user_repo.fetch_user(session.user_id).await { Ok(user) => user, Err(IdentityError::NotFound) => { warn!("user `{}` missing", session.user_id); return Err(StatusCode::UNAUTHORIZED.into_response()); } Err(IdentityError::Database(error)) => { warn!(?error, "failed to load user"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } Err(_) => { warn!("unexpected error loading user"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } }; let ctx = RequestContext { user, session_id: session.id, access_token_expires_at: Utc::now(), }; match session_repo.touch(session.id).await { Ok(_) => {} Err(error) => warn!(?error, "failed to update session last-used timestamp"), } Ok(ctx) } ================================================ FILE: crates/relay-tunnel/src/server_bin/config.rs ================================================ use std::env; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use secrecy::SecretString; #[derive(Debug, Clone)] pub struct RelayServerConfig { pub database_url: String, pub listen_addr: String, pub jwt_secret: SecretString, } #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("environment variable `{0}` is not set")] MissingVar(&'static str), #[error("invalid value for environment variable `{0}`")] InvalidVar(&'static str), } impl RelayServerConfig { pub fn from_env() -> Result { let database_url = env::var("SERVER_DATABASE_URL") .or_else(|_| env::var("DATABASE_URL")) .map_err(|_| ConfigError::MissingVar("DATABASE_URL"))?; let listen_addr = env::var("RELAY_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8082".to_string()); let jwt_secret_str = env::var("VIBEKANBAN_REMOTE_JWT_SECRET") .map_err(|_| ConfigError::MissingVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; validate_jwt_secret(&jwt_secret_str)?; let jwt_secret = SecretString::new(jwt_secret_str.into()); Ok(Self { database_url, listen_addr, jwt_secret, }) } } fn validate_jwt_secret(secret: &str) -> Result<(), ConfigError> { let decoded = BASE64_STANDARD .decode(secret.as_bytes()) .map_err(|_| ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; if decoded.len() < 32 { return Err(ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET")); } Ok(()) } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/auth_sessions.rs ================================================ pub use api_types::AuthSession; use chrono::Duration; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum AuthSessionError { #[error("auth session not found")] NotFound, #[error(transparent)] Database(#[from] sqlx::Error), } pub const MAX_SESSION_INACTIVITY_DURATION: Duration = Duration::days(365); pub struct AuthSessionRepository<'a> { pool: &'a PgPool, } impl<'a> AuthSessionRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn get(&self, session_id: Uuid) -> Result { sqlx::query_as!( AuthSession, r#" SELECT id AS "id!", user_id AS "user_id!: Uuid", created_at AS "created_at!", last_used_at AS "last_used_at?", revoked_at AS "revoked_at?", refresh_token_id AS "refresh_token_id?", refresh_token_issued_at AS "refresh_token_issued_at?" FROM auth_sessions WHERE id = $1 "#, session_id ) .fetch_optional(self.pool) .await? .ok_or(AuthSessionError::NotFound) } pub async fn touch(&self, session_id: Uuid) -> Result<(), AuthSessionError> { sqlx::query!( r#" UPDATE auth_sessions SET last_used_at = date_trunc('day', NOW()) WHERE id = $1 AND ( last_used_at IS NULL OR last_used_at < date_trunc('day', NOW()) ) "#, session_id ) .execute(self.pool) .await?; Ok(()) } pub async fn revoke(&self, session_id: Uuid) -> Result<(), AuthSessionError> { sqlx::query!( r#" UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1 "#, session_id ) .execute(self.pool) .await?; Ok(()) } } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/hosts.rs ================================================ use api_types::RelaySession; use sqlx::PgPool; use uuid::Uuid; use super::identity_errors::IdentityError; pub struct HostRepository<'a> { pool: &'a PgPool, } impl<'a> HostRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } /// Find or create a host for the given user and machine identity. /// If a host with the same owner and machine_id exists, returns it and updates name/version. /// Otherwise, creates a new one. pub async fn upsert_host( &self, owner_user_id: Uuid, machine_id: &str, name: &str, agent_version: Option<&str>, ) -> Result { let row = sqlx::query!( r#" INSERT INTO hosts ( owner_user_id, shared_with_organization_id, machine_id, name, status, agent_version ) VALUES ($1, NULL, $2, $3, 'offline', $4) ON CONFLICT (owner_user_id, machine_id) DO UPDATE SET name = EXCLUDED.name, agent_version = COALESCE(EXCLUDED.agent_version, hosts.agent_version), updated_at = NOW() RETURNING id AS "id!: Uuid" "#, owner_user_id, machine_id, name, agent_version ) .fetch_one(self.pool) .await?; Ok(row.id) } pub async fn assert_host_access( &self, host_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let row = sqlx::query!( r#" SELECT EXISTS ( SELECT 1 FROM hosts h LEFT JOIN organization_member_metadata om ON om.organization_id = h.shared_with_organization_id AND om.user_id = $2 WHERE h.id = $1 AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL) ) AS "allowed!" "#, host_id, user_id ) .fetch_one(self.pool) .await?; if row.allowed { Ok(()) } else { Err(IdentityError::PermissionDenied) } } pub async fn is_host_online(&self, host_id: Uuid) -> Result { let row = sqlx::query!(r#"SELECT status FROM hosts WHERE id = $1"#, host_id) .fetch_optional(self.pool) .await?; Ok(row.map(|r| r.status == "online").unwrap_or(false)) } pub async fn get_session_for_requester( &self, session_id: Uuid, request_user_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( RelaySession, r#" SELECT id AS "id!: Uuid", host_id AS "host_id!: Uuid", request_user_id AS "request_user_id!: Uuid", state, created_at, expires_at, claimed_at, ended_at FROM relay_sessions WHERE id = $1 AND request_user_id = $2 "#, session_id, request_user_id ) .fetch_optional(self.pool) .await } pub async fn mark_session_active(&self, session_id: Uuid) -> Result { sqlx::query_as!( RelaySession, r#" UPDATE relay_sessions SET state = 'active', claimed_at = COALESCE(claimed_at, NOW()) WHERE id = $1 RETURNING id AS "id!: Uuid", host_id AS "host_id!: Uuid", request_user_id AS "request_user_id!: Uuid", state, created_at, expires_at, claimed_at, ended_at "#, session_id ) .fetch_one(self.pool) .await } pub async fn mark_session_expired(&self, session_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE relay_sessions SET state = 'expired', ended_at = COALESCE(ended_at, NOW()) WHERE id = $1 "#, session_id ) .execute(self.pool) .await?; Ok(()) } pub async fn mark_host_online( &self, host_id: Uuid, agent_version: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE hosts SET status = 'online', last_seen_at = NOW(), agent_version = COALESCE($2, agent_version), updated_at = NOW() WHERE id = $1 "#, host_id, agent_version ) .execute(self.pool) .await?; Ok(()) } pub async fn mark_host_offline(&self, host_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE hosts SET status = 'offline', updated_at = NOW() WHERE id = $1 "#, host_id ) .execute(self.pool) .await?; Ok(()) } } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/identity_errors.rs ================================================ use thiserror::Error; #[derive(Debug, Error)] pub enum IdentityError { #[error("identity record not found")] NotFound, #[error("permission denied")] PermissionDenied, #[error(transparent)] Database(#[from] sqlx::Error), } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/mod.rs ================================================ pub mod auth_sessions; pub mod hosts; pub mod identity_errors; pub mod relay_auth_codes; pub mod relay_browser_sessions; pub mod users; use sqlx::{PgPool, postgres::PgPoolOptions}; pub async fn create_pool(database_url: &str) -> Result { PgPoolOptions::new() .max_connections(10) .connect(database_url) .await } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/relay_auth_codes.rs ================================================ use chrono::{Duration, Utc}; use sha2::{Digest, Sha256}; use sqlx::PgPool; use uuid::Uuid; const RELAY_AUTH_CODE_TTL_SECS: i64 = 30; pub struct RelayAuthCodeRepository<'a> { pool: &'a PgPool, } impl<'a> RelayAuthCodeRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } /// Create a one-time relay auth code and return its plaintext value. pub async fn create( &self, host_id: Uuid, relay_cookie_value: &str, ) -> Result { let code = Uuid::new_v4().to_string(); let code_hash = hash_code(&code); let expires_at = Utc::now() + Duration::seconds(RELAY_AUTH_CODE_TTL_SECS); sqlx::query!( r#" INSERT INTO relay_auth_codes (code_hash, host_id, relay_cookie_value, expires_at) VALUES ($1, $2, $3, $4) "#, code_hash, host_id, relay_cookie_value, expires_at ) .execute(self.pool) .await?; Ok(code) } /// Atomically redeem a code for the expected host. pub async fn redeem_for_host( &self, code: &str, expected_host_id: Uuid, ) -> Result, sqlx::Error> { let code_hash = hash_code(code); let redeemed = sqlx::query!( r#" UPDATE relay_auth_codes SET consumed_at = NOW() WHERE code_hash = $1 AND host_id = $2 AND consumed_at IS NULL AND expires_at > NOW() RETURNING relay_cookie_value "#, code_hash, expected_host_id ) .fetch_optional(self.pool) .await?; Ok(redeemed.map(|row| row.relay_cookie_value)) } } fn hash_code(code: &str) -> String { let digest = Sha256::digest(code.as_bytes()); format!("{:x}", digest) } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/relay_browser_sessions.rs ================================================ use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, Clone, sqlx::FromRow)] pub struct RelayBrowserSession { pub id: Uuid, pub host_id: Uuid, pub user_id: Uuid, pub auth_session_id: Uuid, pub created_at: DateTime, pub last_used_at: Option>, pub revoked_at: Option>, } pub struct RelayBrowserSessionRepository<'a> { pool: &'a PgPool, } impl<'a> RelayBrowserSessionRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn create( &self, host_id: Uuid, user_id: Uuid, auth_session_id: Uuid, ) -> Result { sqlx::query_as!( RelayBrowserSession, r#" INSERT INTO relay_browser_sessions (host_id, user_id, auth_session_id) VALUES ($1, $2, $3) RETURNING id AS "id!: Uuid", host_id AS "host_id!: Uuid", user_id AS "user_id!: Uuid", auth_session_id AS "auth_session_id!: Uuid", created_at, last_used_at, revoked_at "#, host_id, user_id, auth_session_id ) .fetch_one(self.pool) .await } pub async fn get(&self, session_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( RelayBrowserSession, r#" SELECT id AS "id!: Uuid", host_id AS "host_id!: Uuid", user_id AS "user_id!: Uuid", auth_session_id AS "auth_session_id!: Uuid", created_at, last_used_at, revoked_at FROM relay_browser_sessions WHERE id = $1 "#, session_id ) .fetch_optional(self.pool) .await } pub async fn touch(&self, session_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE relay_browser_sessions SET last_used_at = date_trunc('day', NOW()) WHERE id = $1 AND ( last_used_at IS NULL OR last_used_at < date_trunc('day', NOW()) ) "#, session_id ) .execute(self.pool) .await?; Ok(()) } pub async fn revoke(&self, session_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( r#" UPDATE relay_browser_sessions SET revoked_at = NOW() WHERE id = $1 "#, session_id ) .execute(self.pool) .await?; Ok(()) } } ================================================ FILE: crates/relay-tunnel/src/server_bin/db/users.rs ================================================ use api_types::User; use sqlx::{PgPool, query_as}; use uuid::Uuid; use super::identity_errors::IdentityError; pub struct UserRepository<'a> { pool: &'a PgPool, } impl<'a> UserRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn fetch_user(&self, user_id: Uuid) -> Result { query_as!( User, r#" SELECT id AS "id!: Uuid", email AS "email!", first_name AS "first_name?", last_name AS "last_name?", username AS "username?", created_at AS "created_at!", updated_at AS "updated_at!" FROM users WHERE id = $1 "#, user_id ) .fetch_optional(self.pool) .await? .ok_or(IdentityError::NotFound) } } ================================================ FILE: crates/relay-tunnel/src/server_bin/mod.rs ================================================ pub mod auth; pub mod config; pub mod db; pub mod relay_registry; pub mod routes; pub mod state; ================================================ FILE: crates/relay-tunnel/src/server_bin/relay_registry.rs ================================================ //! In-memory relay registry for active tunnel connections. //! //! Each connected local server gets an `ActiveRelay` entry. The remote //! relay proxy looks up relays by host ID and opens yamux streams over //! the existing control connection. One-time auth codes are DB-backed. use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; use uuid::Uuid; use crate::server::SharedControl; /// An active relay connection from a local server. pub struct ActiveRelay { /// Open yamux streams to the connected local host. pub control: SharedControl, } impl ActiveRelay { pub fn new(control: SharedControl) -> Self { Self { control } } } /// Registry of all active relay connections, indexed by host ID. #[derive(Default, Clone)] pub struct RelayRegistry { inner: Arc>>>, } impl RelayRegistry { /// Register a relay for a host. Replaces any existing relay for that host. pub async fn insert(&self, host_id: Uuid, relay: Arc) { self.inner.lock().await.insert(host_id, relay); } /// Remove the relay for a host. pub async fn remove(&self, host_id: &Uuid) { self.inner.lock().await.remove(host_id); } /// Remove the relay for a host only when it still matches the provided relay. pub async fn remove_if_same(&self, host_id: &Uuid, relay: &Arc) -> bool { let mut relays = self.inner.lock().await; if relays .get(host_id) .is_some_and(|current| Arc::ptr_eq(current, relay)) { relays.remove(host_id); true } else { false } } /// Look up the active relay for a host. pub async fn get(&self, host_id: &Uuid) -> Option> { self.inner.lock().await.get(host_id).cloned() } } ================================================ FILE: crates/relay-tunnel/src/server_bin/routes/auth_code.rs ================================================ //! Generate one-time auth codes for relay browser-session exchange. use api_types::RelaySessionAuthCodeResponse; use axum::{ Extension, Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; use chrono::Utc; use uuid::Uuid; use super::super::{ auth::RequestContext, db::{ hosts::HostRepository, relay_auth_codes::RelayAuthCodeRepository, relay_browser_sessions::RelayBrowserSessionRepository, }, state::RelayAppState, }; /// Generate a one-time auth code for a relay browser-session exchange. pub async fn relay_session_auth_code( State(state): State, Path(session_id): Path, Extension(ctx): Extension, ) -> Result, Response> { let repo = HostRepository::new(&state.pool); let session = match repo .get_session_for_requester(session_id, ctx.user.id) .await { Ok(Some(session)) => session, Ok(None) => return Err((StatusCode::NOT_FOUND, "Relay session not found").into_response()), Err(error) => { tracing::warn!(?error, "failed to load relay session"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } }; if session.ended_at.is_some() || session.state == "expired" { return Err((StatusCode::GONE, "Relay session expired").into_response()); } if session.expires_at <= Utc::now() { if let Err(error) = repo.mark_session_expired(session.id).await { tracing::warn!(?error, "failed to mark relay session expired"); } return Err((StatusCode::GONE, "Relay session expired").into_response()); } // Check in-memory registry — the relay-server knows exactly which hosts are connected if state.relay_registry.get(&session.host_id).await.is_none() { return Err((StatusCode::NOT_FOUND, "Host is not connected").into_response()); } if let Err(error) = repo.mark_session_active(session.id).await { tracing::warn!(?error, "failed to mark relay session active"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } let relay_browser_session_repo = RelayBrowserSessionRepository::new(&state.pool); let relay_browser_session = match relay_browser_session_repo .create(session.host_id, ctx.user.id, ctx.session_id) .await { Ok(session) => session, Err(error) => { tracing::warn!(?error, "failed to create relay browser session"); return Err(( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate auth code", ) .into_response()); } }; let browser_session_id = relay_browser_session.id.to_string(); let auth_code_repo = RelayAuthCodeRepository::new(&state.pool); let code = match auth_code_repo .create(session.host_id, &browser_session_id) .await { Ok(code) => code, Err(error) => { tracing::warn!(?error, "failed to create relay auth code"); return Err(( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate auth code", ) .into_response()); } }; Ok(Json(RelaySessionAuthCodeResponse { session_id: session.id, code, })) } ================================================ FILE: crates/relay-tunnel/src/server_bin/routes/connect.rs ================================================ //! WebSocket control channel handler for local server connections. use std::sync::Arc; use axum::{ Extension, extract::{Query, State, ws::WebSocketUpgrade}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use uuid::Uuid; use super::super::{ auth::RequestContext, db::hosts::HostRepository, relay_registry::{ActiveRelay, RelayRegistry}, state::RelayAppState, }; use crate::server::run_control_channel; #[derive(Debug, Deserialize)] pub struct ConnectQuery { pub machine_id: String, pub name: String, #[serde(default)] pub agent_version: Option, } /// Local server connects here to establish a relay control channel. /// The host record is upserted from the authenticated user + machine_id query param. pub async fn relay_connect( State(state): State, Extension(ctx): Extension, Query(query): Query, ws: WebSocketUpgrade, ) -> Response { let repo = HostRepository::new(&state.pool); let host_id = match repo .upsert_host( ctx.user.id, &query.machine_id, &query.name, query.agent_version.as_deref(), ) .await { Ok(id) => id, Err(error) => { tracing::error!(?error, "failed to upsert host for relay connect"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; if let Err(error) = repo .mark_host_online(host_id, query.agent_version.as_deref()) .await { tracing::warn!(?error, "failed to mark host online"); } let registry = state.relay_registry.clone(); let pool = state.pool.clone(); ws.on_upgrade(move |socket| async move { handle_control_channel(socket, pool, registry, host_id).await; }) } async fn handle_control_channel( socket: axum::extract::ws::WebSocket, pool: sqlx::PgPool, registry: RelayRegistry, host_id: Uuid, ) { let registry_for_connect = registry.clone(); let connected_relay = Arc::new(tokio::sync::Mutex::new(None::>)); let connected_relay_for_connect = connected_relay.clone(); let run_result = run_control_channel(socket, move |control| { let registry_for_connect = registry_for_connect.clone(); let connected_relay_for_connect = connected_relay_for_connect.clone(); async move { let relay = Arc::new(ActiveRelay::new(control)); registry_for_connect.insert(host_id, relay.clone()).await; *connected_relay_for_connect.lock().await = Some(relay); tracing::debug!(%host_id, "Relay control channel connected"); } }) .await; if let Err(error) = run_result { tracing::warn!(?error, %host_id, "relay session error"); } let should_mark_offline = if let Some(relay) = connected_relay.lock().await.clone() { registry.remove_if_same(&host_id, &relay).await } else { registry.get(&host_id).await.is_none() }; let repo = HostRepository::new(&pool); if should_mark_offline { if let Err(error) = repo.mark_host_offline(host_id).await { tracing::warn!(?error, "failed to mark host offline"); } } else { tracing::debug!( %host_id, "Relay control channel disconnected; keeping host online because a newer channel is active" ); } tracing::debug!(%host_id, "Relay control channel disconnected"); } ================================================ FILE: crates/relay-tunnel/src/server_bin/routes/mod.rs ================================================ mod auth_code; pub mod connect; pub mod path_routes; use axum::{ Router, http::{HeaderName, StatusCode}, middleware, response::IntoResponse, routing::{any, get, post}, }; use serde::Serialize; use tower_http::{ cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer, ExposeHeaders}, trace::TraceLayer, }; use super::{auth, state::RelayAppState}; pub fn build_router(state: RelayAppState) -> Router { let protected = Router::new() .route("/relay/connect", get(connect::relay_connect)) .route( "/relay/sessions/{session_id}/auth-code", post(auth_code::relay_session_auth_code), ) .layer(middleware::from_fn_with_state( state.clone(), auth::require_session, )); let public = Router::new() .route("/health", get(health)) .route( "/relay/h/{host_id}/exchange", get(path_routes::relay_path_exchange), ) .route( "/relay/h/{host_id}/s/{browser_session_id}", any(path_routes::relay_path_proxy), ) .route( "/relay/h/{host_id}/s/{browser_session_id}/", any(path_routes::relay_path_proxy), ) .route( "/relay/h/{host_id}/s/{browser_session_id}/{*tail}", any(path_routes::relay_path_proxy_with_tail), ); Router::::new() .nest("/v1", protected) .merge(public) .layer( CorsLayer::new() .allow_origin(AllowOrigin::mirror_request()) .allow_methods(AllowMethods::mirror_request()) .allow_headers(AllowHeaders::mirror_request()) .expose_headers(ExposeHeaders::list([ HeaderName::from_static("x-vk-resp-ts"), HeaderName::from_static("x-vk-resp-nonce"), HeaderName::from_static("x-vk-resp-signature"), ])) .allow_credentials(true), ) .layer(TraceLayer::new_for_http()) .with_state(state) } #[derive(Serialize)] struct HealthResponse { status: &'static str, } async fn health() -> impl IntoResponse { (StatusCode::OK, axum::Json(HealthResponse { status: "ok" })) } ================================================ FILE: crates/relay-tunnel/src/server_bin/routes/path_routes.rs ================================================ //! Relay path handlers: auth code exchange and proxy. use axum::{ body::Body, extract::{Path, Query, Request, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use uuid::Uuid; use super::super::{ auth::request_context_from_auth_session_id, db::{ hosts::HostRepository, identity_errors::IdentityError, relay_auth_codes::RelayAuthCodeRepository, relay_browser_sessions::RelayBrowserSessionRepository, }, state::RelayAppState, }; use crate::server::proxy_request_over_control; const RELAY_PROXY_PREFIX: &str = "/relay/h"; #[derive(Debug, Deserialize)] pub(super) struct RelayExchangeQuery { code: String, } /// Handle `GET /relay/h/{host_id}/exchange?code=...`. pub(super) async fn relay_path_exchange( State(state): State, Path(host_id): Path, Query(params): Query, ) -> Response { let auth_code_repo = RelayAuthCodeRepository::new(&state.pool); match auth_code_repo.redeem_for_host(¶ms.code, host_id).await { Ok(Some(browser_session_id)) => { let location = format!("{RELAY_PROXY_PREFIX}/{host_id}/s/{browser_session_id}"); Response::builder() .status(StatusCode::FOUND) .header("location", location) .body(Body::empty()) .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) } Ok(None) => (StatusCode::UNAUTHORIZED, "Invalid or expired code").into_response(), Err(error) => { tracing::warn!(?error, "failed to redeem relay auth code"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } /// Handle `ANY /relay/h/{host_id}/s/{browser_session_id}`. pub(super) async fn relay_path_proxy( State(state): State, Path((host_id, browser_session_id)): Path<(Uuid, Uuid)>, request: Request, ) -> Response { if let Err(response) = validate_browser_session_for_host(&state, browser_session_id, host_id).await { return response; } do_relay_proxy_for_host(&state, host_id, browser_session_id, request).await } /// Handle `ANY /relay/h/{host_id}/s/{browser_session_id}/{*tail}`. pub(super) async fn relay_path_proxy_with_tail( State(state): State, Path((host_id, browser_session_id, _tail)): Path<(Uuid, Uuid, String)>, request: Request, ) -> Response { if let Err(response) = validate_browser_session_for_host(&state, browser_session_id, host_id).await { return response; } do_relay_proxy_for_host(&state, host_id, browser_session_id, request).await } async fn validate_browser_session_for_host( state: &RelayAppState, relay_browser_session_id: Uuid, expected_host_id: Uuid, ) -> Result<(), Response> { let relay_browser_session_repo = RelayBrowserSessionRepository::new(&state.pool); let relay_browser_session = match relay_browser_session_repo .get(relay_browser_session_id) .await { Ok(Some(session)) => session, Ok(None) => return Err(StatusCode::UNAUTHORIZED.into_response()), Err(error) => { tracing::warn!(?error, "failed to load relay browser session"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } }; if relay_browser_session.revoked_at.is_some() { return Err(StatusCode::UNAUTHORIZED.into_response()); } if relay_browser_session.host_id != expected_host_id { return Err((StatusCode::FORBIDDEN, "Host access denied").into_response()); } let ctx = match request_context_from_auth_session_id(state, relay_browser_session.auth_session_id) .await { Ok(ctx) => ctx, Err(response) => { if let Err(error) = relay_browser_session_repo .revoke(relay_browser_session.id) .await { tracing::warn!(?error, "failed to revoke relay browser session"); } return Err(response); } }; if ctx.user.id != relay_browser_session.user_id { tracing::warn!( relay_browser_session_user_id = %relay_browser_session.user_id, auth_session_user_id = %ctx.user.id, relay_browser_session_id = %relay_browser_session.id, "relay browser session user mismatch" ); return Err(StatusCode::UNAUTHORIZED.into_response()); } let host_repo = HostRepository::new(&state.pool); if let Err(error) = host_repo .assert_host_access(expected_host_id, ctx.user.id) .await { return Err(match error { IdentityError::PermissionDenied | IdentityError::NotFound => { (StatusCode::FORBIDDEN, "Host access denied").into_response() } IdentityError::Database(db_error) => { tracing::warn!(?db_error, "failed to validate host access"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } }); } if let Err(error) = relay_browser_session_repo .touch(relay_browser_session.id) .await { tracing::debug!( ?error, relay_browser_session_id = %relay_browser_session.id, "failed to update relay browser session last-used timestamp" ); } Ok(()) } async fn do_relay_proxy_for_host( state: &RelayAppState, host_id: Uuid, browser_session_id: Uuid, request: Request, ) -> Response { let relay = match state.relay_registry.get(&host_id).await { Some(relay) => relay, None => return (StatusCode::NOT_FOUND, "No active relay").into_response(), }; let strip_prefix = format!("{RELAY_PROXY_PREFIX}/{host_id}/s/{browser_session_id}"); proxy_request_over_control(relay.control.as_ref(), request, &strip_prefix).await } ================================================ FILE: crates/relay-tunnel/src/server_bin/state.rs ================================================ use std::sync::Arc; use sqlx::PgPool; use super::{auth::JwtService, config::RelayServerConfig, relay_registry::RelayRegistry}; #[derive(Clone)] pub struct RelayAppState { pub pool: PgPool, pub config: RelayServerConfig, pub jwt: Arc, pub relay_registry: RelayRegistry, } impl RelayAppState { pub fn new(pool: PgPool, config: RelayServerConfig, jwt: Arc) -> Self { Self { pool, config, jwt, relay_registry: RelayRegistry::default(), } } } ================================================ FILE: crates/relay-tunnel/src/tls.rs ================================================ use tokio_tungstenite::Connector; /// Build TLS connector for the relay WebSocket client. /// /// In debug builds, returns a connector that accepts all certificates (equivalent /// to `danger_accept_invalid_certs`) so that Caddy's internal CA and other dev /// certs work. In release builds, returns `None` to use the default webpki-roots /// validation. pub fn ws_connector() -> Option { #[cfg(debug_assertions)] { use std::sync::Arc; let config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAllCerts)) .with_no_client_auth(); Some(Connector::Rustls(Arc::new(config))) } #[cfg(not(debug_assertions))] { None } } #[cfg(debug_assertions)] #[derive(Debug)] struct AcceptAllCerts; #[cfg(debug_assertions)] impl rustls::client::danger::ServerCertVerifier for AcceptAllCerts { fn verify_server_cert( &self, _end_entity: &rustls::pki_types::CertificateDer<'_>, _intermediates: &[rustls::pki_types::CertificateDer<'_>], _server_name: &rustls::pki_types::ServerName<'_>, _ocsp_response: &[u8], _now: rustls::pki_types::UnixTime, ) -> Result { Ok(rustls::client::danger::ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { rustls::crypto::aws_lc_rs::default_provider() .signature_verification_algorithms .supported_schemes() } } ================================================ FILE: crates/relay-tunnel/src/ws_io.rs ================================================ use std::{ io, marker::PhantomData, pin::Pin, task::{Context, Poll, ready}, }; use bytes::BytesMut; use futures::{Sink, Stream}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; pub enum WsIoReadMessage { Data(Vec), Skip, Eof, } /// Adapts a WebSocket message stream into an AsyncRead/AsyncWrite byte stream. pub struct WsMessageStreamIo { ws: S, read_buf: BytesMut, read_message: FRead, write_message: FWrite, _message: PhantomData M>, } impl WsMessageStreamIo { pub fn new(ws: S, read_message: FRead, write_message: FWrite) -> Self { Self { ws, read_buf: BytesMut::new(), read_message, write_message, _message: PhantomData, } } } impl AsyncRead for WsMessageStreamIo where S: Stream> + Unpin, E: std::fmt::Display, FRead: Fn(M) -> WsIoReadMessage + Unpin, FWrite: Fn(Vec) -> M + Unpin, { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { loop { let this = self.as_mut().get_mut(); if !this.read_buf.is_empty() { let n = buf.remaining().min(this.read_buf.len()); buf.put_slice(&this.read_buf.split_to(n)); return Poll::Ready(Ok(())); } let message = match ready!(Pin::new(&mut this.ws).poll_next(cx)) { Some(Ok(message)) => message, Some(Err(error)) => return Poll::Ready(Err(io::Error::other(error.to_string()))), None => return Poll::Ready(Ok(())), }; match (this.read_message)(message) { WsIoReadMessage::Data(data) => this.read_buf.extend_from_slice(&data), WsIoReadMessage::Skip => continue, WsIoReadMessage::Eof => return Poll::Ready(Ok(())), } } } } impl AsyncWrite for WsMessageStreamIo where S: Sink + Unpin, E: std::fmt::Display, FRead: Fn(M) -> WsIoReadMessage + Unpin, FWrite: Fn(Vec) -> M + Unpin, { fn poll_write( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { if buf.is_empty() { return Poll::Ready(Ok(0)); } let this = self.as_mut().get_mut(); ready!(Pin::new(&mut this.ws).poll_ready(cx)) .map_err(|error| io::Error::other(error.to_string()))?; Pin::new(&mut this.ws) .start_send((this.write_message)(buf.to_vec())) .map_err(|error| io::Error::other(error.to_string()))?; Poll::Ready(Ok(buf.len())) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.as_mut().get_mut(); ready!(Pin::new(&mut this.ws).poll_flush(cx)) .map_err(|error| io::Error::other(error.to_string()))?; Poll::Ready(Ok(())) } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.as_mut().get_mut(); ready!(Pin::new(&mut this.ws).poll_close(cx)) .map_err(|error| io::Error::other(error.to_string()))?; Poll::Ready(Ok(())) } } ================================================ FILE: crates/remote/.sqlx/query-00f50fdb65f4126b197b523f6fc1870571c4c121c32e0c3393f6770fc3608e95.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n user_id,\n state_token,\n expires_at,\n created_at\n FROM github_app_pending_installations\n WHERE state_token = $1 AND expires_at > NOW()\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "state_token", "type_info": "Text" }, { "ordinal": 4, "name": "expires_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "00f50fdb65f4126b197b523f6fc1870571c4c121c32e0c3393f6770fc3608e95" } ================================================ FILE: crates/remote/.sqlx/query-00f5a09dfd00355a8657007f6d7b3a2a98547db4acccd485cec20d8fd29815ad.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM workspaces\n WHERE project_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "00f5a09dfd00355a8657007f6d7b3a2a98547db4acccd485cec20d8fd29815ad" } ================================================ FILE: crates/remote/.sqlx/query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE organization_invitations\n SET status = 'expired'\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e" } ================================================ FILE: crates/remote/.sqlx/query-082aaf51a023c8ccb44002ce48287acd8ef90b0f4c8338447c6e5370ca93390b.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\n VALUES ($1, $2, 'token_rotation')\n ON CONFLICT (token_id) DO NOTHING\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "082aaf51a023c8ccb44002ce48287acd8ef90b0f4c8338447c6e5370ca93390b" } ================================================ FILE: crates/remote/.sqlx/query-08fa6f887e954e3b6921f84bbd412b4c3fc5dc1df0b9a5ea3fa4a4b07a86bb55.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT b.project_id\n FROM attachments a\n INNER JOIN blobs b ON b.id = a.blob_id\n WHERE a.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "project_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "08fa6f887e954e3b6921f84bbd412b4c3fc5dc1df0b9a5ea3fa4a4b07a86bb55" } ================================================ FILE: crates/remote/.sqlx/query-0a57abb390861f8e9ce1da411934bef0a1a4edcea151cbf78fdf4cb510a0d450.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT p.organization_id\n FROM issues i\n INNER JOIN projects p ON p.id = i.project_id\n WHERE i.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "0a57abb390861f8e9ce1da411934bef0a1a4edcea151cbf78fdf4cb510a0d450" } ================================================ FILE: crates/remote/.sqlx/query-0c5dfb11325fb2f0ea279c9406d593376bece575358831870012d125fd053be3.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_comment_reactions\n WHERE comment_id IN (SELECT id FROM issue_comments WHERE issue_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "comment_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "emoji!", "type_info": "Varchar" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "0c5dfb11325fb2f0ea279c9406d593376bece575358831870012d125fd053be3" } ================================================ FILE: crates/remote/.sqlx/query-0df35d620c891a94f62e7e3f7afb60819783f961be1dd36cabb478c5e3ad23c0.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_assignees (id, issue_id, user_id)\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n assigned_at AS \"assigned_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "assigned_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "0df35d620c891a94f62e7e3f7afb60819783f961be1dd36cabb478c5e3ad23c0" } ================================================ FILE: crates/remote/.sqlx/query-10428c897273798508759a89323d4fb181081eb5ffea40ef41a4d5437b7b6849.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\"\n FROM issue_followers\n WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "10428c897273798508759a89323d4fb181081eb5ffea40ef41a4d5437b7b6849" } ================================================ FILE: crates/remote/.sqlx/query-11eede7c3a324ffa6266ee5c3fe3fdb2bd3b9e894fcabeece1e8d2201d18dcc6.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE oauth_handoffs\n SET\n status = 'authorized',\n error_code = NULL,\n user_id = $2,\n session_id = $3,\n app_code_hash = $4,\n encrypted_provider_tokens = $5,\n authorized_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Text", "Text" ] }, "nullable": [] }, "hash": "11eede7c3a324ffa6266ee5c3fe3fdb2bd3b9e894fcabeece1e8d2201d18dcc6" } ================================================ FILE: crates/remote/.sqlx/query-12eb8caf8044a790e7390882bc07d8c737581e0926d473b2e0a9eaccdd0a8674.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM attachments\n WHERE id = $1\n RETURNING\n id AS \"id!: Uuid\",\n blob_id AS \"blob_id!: Uuid\",\n issue_id AS \"issue_id?: Uuid\",\n comment_id AS \"comment_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at?: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, true ] }, "hash": "12eb8caf8044a790e7390882bc07d8c737581e0926d473b2e0a9eaccdd0a8674" } ================================================ FILE: crates/remote/.sqlx/query-1565680821f93069b2b5c109a7d1ba10889ca9b98c848895de6ef2c3ef4dffa0.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n blob_id AS \"blob_id!: Uuid\",\n issue_id AS \"issue_id?: Uuid\",\n comment_id AS \"comment_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at?: DateTime\"\n FROM attachments\n WHERE expires_at IS NOT NULL AND expires_at < NOW()\n ORDER BY expires_at ASC\n LIMIT $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false, true, true, false, true ] }, "hash": "1565680821f93069b2b5c109a7d1ba10889ca9b98c848895de6ef2c3ef4dffa0" } ================================================ FILE: crates/remote/.sqlx/query-16abe1e4d69bf90ed05d8651b688e3be23a74d8dd3957a976c7b757660d5b169.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM workspaces WHERE local_workspace_id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "16abe1e4d69bf90ed05d8651b688e3be23a74d8dd3957a976c7b757660d5b169" } ================================================ FILE: crates/remote/.sqlx/query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT role AS \"role!: MemberRole\"\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n FOR UPDATE\n ", "describe": { "columns": [ { "ordinal": 0, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false ] }, "hash": "174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e" } ================================================ FILE: crates/remote/.sqlx/query-185b7fc8f6f22b7c29950a490d46bb16c4fec50cf6e8dc988f3a2c942be909c0.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO pull_requests (\n id, url, number, status, merged_at, merge_commit_sha,\n target_branch_name, issue_id, workspace_id\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING\n id AS \"id!: Uuid\",\n url AS \"url!: String\",\n number AS \"number!: i32\",\n status AS \"status!: PullRequestStatus\",\n merged_at AS \"merged_at: DateTime\",\n merge_commit_sha AS \"merge_commit_sha: String\",\n target_branch_name AS \"target_branch_name!: String\",\n issue_id AS \"issue_id!: Uuid\",\n workspace_id AS \"workspace_id: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "url!: String", "type_info": "Text" }, { "ordinal": 2, "name": "number!: i32", "type_info": "Int4" }, { "ordinal": 3, "name": "status!: PullRequestStatus", "type_info": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } }, { "ordinal": 4, "name": "merged_at: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "merge_commit_sha: String", "type_info": "Varchar" }, { "ordinal": 6, "name": "target_branch_name!: String", "type_info": "Text" }, { "ordinal": 7, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 8, "name": "workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 9, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text", "Int4", { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } }, "Timestamptz", "Varchar", "Text", "Uuid", "Uuid" ] }, "nullable": [ false, false, false, false, true, true, false, false, true, false, false ] }, "hash": "185b7fc8f6f22b7c29950a490d46bb16c4fec50cf6e8dc988f3a2c942be909c0" } ================================================ FILE: crates/remote/.sqlx/query-187b173294e46a013a48040ca4375b65df44215d8883cae88123f762880507e9.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO organizations (name, slug, issue_prefix)\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n issue_prefix AS \"issue_prefix!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Text", "Varchar" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "187b173294e46a013a48040ca4375b65df44215d8883cae88123f762880507e9" } ================================================ FILE: crates/remote/.sqlx/query-18ae849cdeff678538d5bd6782e16780da9db40e4d892a75d7d244f247db5c04.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE reviews\n SET status = 'failed'\n WHERE id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "18ae849cdeff678538d5bd6782e16780da9db40e4d892a75d7d244f247db5c04" } ================================================ FILE: crates/remote/.sqlx/query-18f2fc4074de23b6b2a0c2c70403d6a1eaa57e1fda5063d9f3a292e8aab61ede.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM workspaces\n WHERE owner_user_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "18f2fc4074de23b6b2a0c2c70403d6a1eaa57e1fda5063d9f3a292e8aab61ede" } ================================================ FILE: crates/remote/.sqlx/query-198df1da04fb3ffee213718de87fa49d5032545d55d45a7cb0c62dcc60db5f78.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n related_issue_id AS \"related_issue_id!: Uuid\",\n relationship_type AS \"relationship_type!: IssueRelationshipType\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_relationships\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "related_issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "relationship_type!: IssueRelationshipType", "type_info": { "Custom": { "name": "issue_relationship_type", "kind": { "Enum": [ "blocking", "related", "has_duplicate" ] } } } }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "198df1da04fb3ffee213718de87fa49d5032545d55d45a7cb0c62dcc60db5f78" } ================================================ FILE: crates/remote/.sqlx/query-1b2c4d4205244ed0fa457ebc3b42147c9446a7efb5e205cd85aa780f99824b88.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n a.id AS \"id!: Uuid\",\n a.blob_id AS \"blob_id!: Uuid\",\n a.issue_id AS \"issue_id?: Uuid\",\n a.comment_id AS \"comment_id?: Uuid\",\n a.created_at AS \"created_at!: DateTime\",\n a.expires_at AS \"expires_at?: DateTime\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\"\n FROM attachments a\n INNER JOIN blobs b ON b.id = a.blob_id\n WHERE a.issue_id = $1\n ORDER BY a.created_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 7, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 8, "name": "original_name!", "type_info": "Text" }, { "ordinal": 9, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 10, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 11, "name": "hash!", "type_info": "Text" }, { "ordinal": 12, "name": "width?", "type_info": "Int4" }, { "ordinal": 13, "name": "height?", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, true, false, true, false, true, false, false, true, true ] }, "hash": "1b2c4d4205244ed0fa457ebc3b42147c9446a7efb5e205cd85aa780f99824b88" } ================================================ FILE: crates/remote/.sqlx/query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ) AS \"exists!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5" } ================================================ FILE: crates/remote/.sqlx/query-1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM workspaces WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "1c2201b0ca9305283634fe5c72df6eac3ad954c1238088a84a4b9085b1dbdb74" } ================================================ FILE: crates/remote/.sqlx/query-1c57e525a7060361832601f158977fffec60c36534ec8eb9affbdf648c280334.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO relay_sessions (host_id, request_user_id, state, expires_at)\n VALUES ($1, $2, 'requested', $3)\n RETURNING\n id AS \"id!: Uuid\",\n host_id AS \"host_id!: Uuid\",\n request_user_id AS \"request_user_id!: Uuid\",\n state,\n created_at,\n expires_at,\n claimed_at,\n ended_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "host_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "request_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "state", "type_info": "Text" }, { "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "claimed_at", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "ended_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, false, true, true ] }, "hash": "1c57e525a7060361832601f158977fffec60c36534ec8eb9affbdf648c280334" } ================================================ FILE: crates/remote/.sqlx/query-1d612faf67c945cfe22cfd7ab6b6d360fbce8dceb7b64c4d17b4df108434c822.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE notifications\n SET seen = COALESCE($1, seen),\n dismissed_at = CASE\n WHEN $1 = true AND dismissed_at IS NULL THEN NOW()\n ELSE dismissed_at\n END\n WHERE id = $2\n RETURNING\n id,\n organization_id,\n user_id,\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Bool", "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true, false, true, false ] }, "hash": "1d612faf67c945cfe22cfd7ab6b6d360fbce8dceb7b64c4d17b4df108434c822" } ================================================ FILE: crates/remote/.sqlx/query-1d6f13e86897b0885ac3caa36bd56a8685e137a5e22545776b16a5814f225211.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_followers (id, issue_id, user_id)\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "1d6f13e86897b0885ac3caa36bd56a8685e137a5e22545776b16a5814f225211" } ================================================ FILE: crates/remote/.sqlx/query-1e2aef04b2d7b1ece13c96ac1dd7718d59c6e8f3dbf0606789fc9f664ac33332.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE project_statuses\n SET\n name = COALESCE($1, name),\n color = COALESCE($2, color),\n sort_order = COALESCE($3, sort_order),\n hidden = COALESCE($4, hidden)\n WHERE id = $5\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Varchar", "Varchar", "Int4", "Bool", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "1e2aef04b2d7b1ece13c96ac1dd7718d59c6e8f3dbf0606789fc9f664ac33332" } ================================================ FILE: crates/remote/.sqlx/query-1ea6478b8325ce0313727f756715c988d0c03ccb74a87e67325c73c03a5dcc33.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n user_id,\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n FROM notifications\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true, false, true, false ] }, "hash": "1ea6478b8325ce0313727f756715c988d0c03ccb74a87e67325c73c03a5dcc33" } ================================================ FILE: crates/remote/.sqlx/query-209f1b560de8e99de312394860b42251b0272fc7f8f57ea50c9a16fb026b5ae4.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_assignees WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "209f1b560de8e99de312394860b42251b0272fc7f8f57ea50c9a16fb026b5ae4" } ================================================ FILE: crates/remote/.sqlx/query-2129f33c4fdf5d1bf52cfac30238e36ffacaab20fb2cf4111fa70ba4e5aa1bca.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE github_app_repositories\n SET review_enabled = $3\n WHERE id = $1 AND installation_id = $2\n RETURNING\n id,\n installation_id,\n github_repo_id,\n repo_full_name,\n review_enabled,\n created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "installation_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_repo_id", "type_info": "Int8" }, { "ordinal": 3, "name": "repo_full_name", "type_info": "Text" }, { "ordinal": 4, "name": "review_enabled", "type_info": "Bool" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Bool" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "2129f33c4fdf5d1bf52cfac30238e36ffacaab20fb2cf4111fa70ba4e5aa1bca" } ================================================ FILE: crates/remote/.sqlx/query-253ed3e27e9c1798ecadf943e621bf2993ffdf2267e2582679656ccde7a33c67.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\"\n FROM issue_followers\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "253ed3e27e9c1798ecadf943e621bf2993ffdf2267e2582679656ccde7a33c67" } ================================================ FILE: crates/remote/.sqlx/query-28f6198cfd9c7a01e437a72e5cb3e076f5183a457cb6389cb56d047c1dcce439.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_comment_reactions WHERE \"comment_id\" IN (SELECT id FROM issue_comments WHERE \"issue_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "28f6198cfd9c7a01e437a72e5cb3e076f5183a457cb6389cb56d047c1dcce439" } ================================================ FILE: crates/remote/.sqlx/query-2aa7a0c029cf5fde56e413c13af502a0656051e41e1d036805cc427514c37337.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_tags (id, issue_id, tag_id)\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n tag_id AS \"tag_id!: Uuid\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "tag_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "2aa7a0c029cf5fde56e413c13af502a0656051e41e1d036805cc427514c37337" } ================================================ FILE: crates/remote/.sqlx/query-2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS (\n SELECT 1\n FROM hosts h\n LEFT JOIN organization_member_metadata om\n ON om.organization_id = h.shared_with_organization_id\n AND om.user_id = $2\n WHERE h.id = $1\n AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL)\n ) AS \"allowed!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "allowed!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ null ] }, "hash": "2b222c6a2bfc74535ad20d839e8f1aef3d1d64c2b0b96289f1703b08f2a48f68" } ================================================ FILE: crates/remote/.sqlx/query-2b3d84d8febea88a7957efbfd0ca68ee279bc57c6a60afecf9073f46445163a2.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM workspaces\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "2b3d84d8febea88a7957efbfd0ca68ee279bc57c6a60afecf9073f46445163a2" } ================================================ FILE: crates/remote/.sqlx/query-2d85b3d08704ce8475a15a7c8d10a5c1afd97f8ae8e126d26844735f7449fb19.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM tags WHERE \"project_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "2d85b3d08704ce8475a15a7c8d10a5c1afd97f8ae8e126d26844735f7449fb19" } ================================================ FILE: crates/remote/.sqlx/query-2db4c808f8d1f22c6209027007ebeb2bd58580758abf8996797b5338d793f741.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE workspaces SET\n name = CASE WHEN $1 THEN $2 ELSE name END,\n archived = CASE WHEN $3 THEN $4 ELSE archived END,\n files_changed = CASE WHEN $5 THEN $6 ELSE files_changed END,\n lines_added = CASE WHEN $7 THEN $8 ELSE lines_added END,\n lines_removed = CASE WHEN $9 THEN $10 ELSE lines_removed END,\n updated_at = NOW()\n WHERE id = $11\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Bool", "Text", "Bool", "Bool", "Bool", "Int4", "Bool", "Int4", "Bool", "Int4", "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "2db4c808f8d1f22c6209027007ebeb2bd58580758abf8996797b5338d793f741" } ================================================ FILE: crates/remote/.sqlx/query-2f3898ec50ee1386f87786c605069aac78d5177feaabd719b60e54f94f5f535e.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET refresh_token_id = $3,\n refresh_token_issued_at = NOW()\n WHERE id = $1\n AND refresh_token_id = $2\n RETURNING user_id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "user_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid" ] }, "nullable": [ false ] }, "hash": "2f3898ec50ee1386f87786c605069aac78d5177feaabd719b60e54f94f5f535e" } ================================================ FILE: crates/remote/.sqlx/query-301e398b03c6e376d3ebd8dc9373f5724ae535e773588ab75baa29468a495ef4.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT COUNT(*) AS \"count!\" FROM workspaces WHERE issue_id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "count!", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "301e398b03c6e376d3ebd8dc9373f5724ae535e773588ab75baa29468a495ef4" } ================================================ FILE: crates/remote/.sqlx/query-31c99a55082ff59e212e1fe5425b695030dcf4cfe029ffbb1b56813106a563dc.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_accounts\n WHERE provider = $1\n AND provider_user_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "provider!", "type_info": "Text" }, { "ordinal": 3, "name": "provider_user_id!", "type_info": "Text" }, { "ordinal": 4, "name": "email?", "type_info": "Text" }, { "ordinal": 5, "name": "username?", "type_info": "Text" }, { "ordinal": 6, "name": "display_name?", "type_info": "Text" }, { "ordinal": 7, "name": "avatar_url?", "type_info": "Text" }, { "ordinal": 8, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 9, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Text" ] }, "nullable": [ false, false, false, false, true, true, true, true, true, false, false ] }, "hash": "31c99a55082ff59e212e1fe5425b695030dcf4cfe029ffbb1b56813106a563dc" } ================================================ FILE: crates/remote/.sqlx/query-32388086083c01d21b0d4d052519a08002b82751e45aa59b3ac628cc96be2723.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM notifications WHERE \"user_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "32388086083c01d21b0d4d052519a08002b82751e45aa59b3ac628cc96be2723" } ================================================ FILE: crates/remote/.sqlx/query-3239a6b54374bfba7c1ee16f151333563e21af8994d0431acf029e6a2ca08bfd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n installation_id,\n github_repo_id,\n repo_full_name,\n review_enabled,\n created_at\n FROM github_app_repositories\n WHERE installation_id = $1\n ORDER BY repo_full_name\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "installation_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_repo_id", "type_info": "Int8" }, { "ordinal": 3, "name": "repo_full_name", "type_info": "Text" }, { "ordinal": 4, "name": "review_enabled", "type_info": "Bool" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "3239a6b54374bfba7c1ee16f151333563e21af8994d0431acf029e6a2ca08bfd" } ================================================ FILE: crates/remote/.sqlx/query-3690a7ea5e1250ceca638bad754a77df36031d8ca132402cc9256f71a57fa476.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\n SELECT gen_random_uuid(), $1, name, color, sort_order, hidden, NOW()\n FROM UNNEST($2::text[], $3::text[], $4::int[], $5::bool[]) AS t(name, color, sort_order, hidden)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "TextArray", "TextArray", "Int4Array", "BoolArray" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "3690a7ea5e1250ceca638bad754a77df36031d8ca132402cc9256f71a57fa476" } ================================================ FILE: crates/remote/.sqlx/query-37af75dde5f977838d59b57729e9a238d2d2def278d376adc1d4c1d038a918cf.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO projects (\n id, organization_id, name, color, sort_order,\n created_at, updated_at\n )\n VALUES (\n $1,\n $2,\n $3,\n $4,\n COALESCE(\n (SELECT MAX(sort_order) + 1 FROM projects WHERE organization_id = $2),\n 0\n ),\n $5,\n $6\n )\n RETURNING\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Text" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Text", "Varchar", "Timestamptz", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "37af75dde5f977838d59b57729e9a238d2d2def278d376adc1d4c1d038a918cf" } ================================================ FILE: crates/remote/.sqlx/query-389b412ed9b76973a5b1546a24167e0b752467405f024de73101b6c12e1e05f1.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1 FROM revoked_refresh_tokens WHERE token_id = $1\n ) as is_revoked\n ", "describe": { "columns": [ { "ordinal": 0, "name": "is_revoked", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "389b412ed9b76973a5b1546a24167e0b752467405f024de73101b6c12e1e05f1" } ================================================ FILE: crates/remote/.sqlx/query-3e682d961f272a5c1ce20366008889156928c87babc1704d3277ff9a1812193c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_accounts\n WHERE user_id = $1\n ORDER BY provider\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "provider!", "type_info": "Text" }, { "ordinal": 3, "name": "provider_user_id!", "type_info": "Text" }, { "ordinal": 4, "name": "email?", "type_info": "Text" }, { "ordinal": 5, "name": "username?", "type_info": "Text" }, { "ordinal": 6, "name": "display_name?", "type_info": "Text" }, { "ordinal": 7, "name": "avatar_url?", "type_info": "Text" }, { "ordinal": 8, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 9, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, true, true, true, true, true, false, false ] }, "hash": "3e682d961f272a5c1ce20366008889156928c87babc1704d3277ff9a1812193c" } ================================================ FILE: crates/remote/.sqlx/query-3ef67cb768d55e4aa8d551401be7daa6c8a9c76a4218ce776d87db6e6d1c890c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING\n id,\n organization_id,\n user_id,\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } }, "Jsonb", "Uuid", "Uuid", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, true, true, false, true, false ] }, "hash": "3ef67cb768d55e4aa8d551401be7daa6c8a9c76a4218ce776d87db6e6d1c890c" } ================================================ FILE: crates/remote/.sqlx/query-40c9618c70aae933513bd931a3baace6830d78daacfcbd7af69e4f76a234d01c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n github_installation_id,\n github_account_login,\n github_account_type,\n repository_selection,\n installed_by_user_id,\n suspended_at,\n created_at,\n updated_at\n FROM github_app_installations\n WHERE github_account_login = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 3, "name": "github_account_login", "type_info": "Text" }, { "ordinal": 4, "name": "github_account_type", "type_info": "Text" }, { "ordinal": 5, "name": "repository_selection", "type_info": "Text" }, { "ordinal": 6, "name": "installed_by_user_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "suspended_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, false, false, false, true, true, false, false ] }, "hash": "40c9618c70aae933513bd931a3baace6830d78daacfcbd7af69e4f76a234d01c" } ================================================ FILE: crates/remote/.sqlx/query-421ed23a0bff456c54a14068ceed214fa64d0c50e432fcfe40c222991341bf68.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_comments WHERE \"issue_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "421ed23a0bff456c54a14068ceed214fa64d0c50e432fcfe40c222991341bf68" } ================================================ FILE: crates/remote/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET revoked_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe" } ================================================ FILE: crates/remote/.sqlx/query-426eb8216286273dd0066a15ce4508d9fed04d2feccfff81abb4813ebfea9778.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_relationships (id, issue_id, related_issue_id, relationship_type)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n related_issue_id AS \"related_issue_id!: Uuid\",\n relationship_type AS \"relationship_type!: IssueRelationshipType\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "related_issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "relationship_type!: IssueRelationshipType", "type_info": { "Custom": { "name": "issue_relationship_type", "kind": { "Enum": [ "blocking", "related", "has_duplicate" ] } } } }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", { "Custom": { "name": "issue_relationship_type", "kind": { "Enum": [ "blocking", "related", "has_duplicate" ] } } } ] }, "nullable": [ false, false, false, false, false ] }, "hash": "426eb8216286273dd0066a15ce4508d9fed04d2feccfff81abb4813ebfea9778" } ================================================ FILE: crates/remote/.sqlx/query-4274624ba6445ad370380230898232b12365b2336e235b045b1ad25c958c902d.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT organization_id FROM projects WHERE id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "4274624ba6445ad370380230898232b12365b2336e235b045b1ad25c958c902d" } ================================================ FILE: crates/remote/.sqlx/query-4411b08341b6b5516505ef4d218e0e46cebe76085e49f4cb88fcdc40816d1228.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_assignees WHERE \"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "4411b08341b6b5516505ef4d218e0e46cebe76085e49f4cb88fcdc40816d1228" } ================================================ FILE: crates/remote/.sqlx/query-4447c24a9150eb78d81edc26a441a50ee50b8523c92bfe3ccc82b09518608204.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO reviews (id, gh_pr_url, r2_path, pr_title, github_installation_id, pr_owner, pr_repo, pr_number)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING\n id,\n gh_pr_url,\n claude_code_session_id,\n ip_address AS \"ip_address: IpNetwork\",\n review_cache,\n last_viewed_at,\n r2_path,\n deleted_at,\n created_at,\n email,\n pr_title,\n status,\n github_installation_id,\n pr_owner,\n pr_repo,\n pr_number\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "gh_pr_url", "type_info": "Text" }, { "ordinal": 2, "name": "claude_code_session_id", "type_info": "Text" }, { "ordinal": 3, "name": "ip_address: IpNetwork", "type_info": "Inet" }, { "ordinal": 4, "name": "review_cache", "type_info": "Jsonb" }, { "ordinal": 5, "name": "last_viewed_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "r2_path", "type_info": "Text" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "email", "type_info": "Text" }, { "ordinal": 10, "name": "pr_title", "type_info": "Text" }, { "ordinal": 11, "name": "status", "type_info": "Text" }, { "ordinal": 12, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 13, "name": "pr_owner", "type_info": "Text" }, { "ordinal": 14, "name": "pr_repo", "type_info": "Text" }, { "ordinal": 15, "name": "pr_number", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Int8", "Text", "Text", "Int4" ] }, "nullable": [ false, false, true, true, true, true, false, true, false, true, false, false, true, true, true, true ] }, "hash": "4447c24a9150eb78d81edc26a441a50ee50b8523c92bfe3ccc82b09518608204" } ================================================ FILE: crates/remote/.sqlx/query-45010d9fa4bde72535c3f23f06b7aa9dbf01cf287159476852e5f87496d94ea4.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT is_personal FROM organizations WHERE id = $1", "describe": { "columns": [ { "ordinal": 0, "name": "is_personal", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "45010d9fa4bde72535c3f23f06b7aa9dbf01cf287159476852e5f87496d94ea4" } ================================================ FILE: crates/remote/.sqlx/query-4508b7a46677e8da7a397979a22c1a3e1160c7407b94d7baa84d6a3cdc5667c5.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*) as \"count!\"\n FROM reviews\n WHERE ip_address = $1\n AND created_at > $2\n AND deleted_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count!", "type_info": "Int8" } ], "parameters": { "Left": [ "Inet", "Timestamptz" ] }, "nullable": [ null ] }, "hash": "4508b7a46677e8da7a397979a22c1a3e1160c7407b94d7baa84d6a3cdc5667c5" } ================================================ FILE: crates/remote/.sqlx/query-4593ea3d9f66cb2618bf444ddbab1e8f2b790471f32aaf192e93f9226fc042bc.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT p.organization_id\n FROM blobs b\n INNER JOIN projects p ON p.id = b.project_id\n WHERE b.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "4593ea3d9f66cb2618bf444ddbab1e8f2b790471f32aaf192e93f9226fc042bc" } ================================================ FILE: crates/remote/.sqlx/query-471944787bb9b58a1b30628f28ab8088f60bf3390bfaddbae993e87df89b8844.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n github_installation_id,\n github_account_login,\n github_account_type,\n repository_selection,\n installed_by_user_id,\n suspended_at,\n created_at,\n updated_at\n FROM github_app_installations\n WHERE github_installation_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 3, "name": "github_account_login", "type_info": "Text" }, { "ordinal": 4, "name": "github_account_type", "type_info": "Text" }, { "ordinal": 5, "name": "repository_selection", "type_info": "Text" }, { "ordinal": 6, "name": "installed_by_user_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "suspended_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Int8" ] }, "nullable": [ false, false, false, false, false, false, true, true, false, false ] }, "hash": "471944787bb9b58a1b30628f28ab8088f60bf3390bfaddbae993e87df89b8844" } ================================================ FILE: crates/remote/.sqlx/query-47c186223fb7e3c66fff44c1029cf04fb872064a1d8c14bf7d76a841cfe904a6.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM blobs\n WHERE id = $1\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n thumbnail_blob_path AS \"thumbnail_blob_path?\",\n original_name AS \"original_name!\",\n mime_type AS \"mime_type?\",\n size_bytes AS \"size_bytes!\",\n hash AS \"hash!\",\n width AS \"width?\",\n height AS \"height?\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 4, "name": "original_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 6, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 7, "name": "hash!", "type_info": "Text" }, { "ordinal": 8, "name": "width?", "type_info": "Int4" }, { "ordinal": 9, "name": "height?", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, false, true, false, false, true, true, false, false ] }, "hash": "47c186223fb7e3c66fff44c1029cf04fb872064a1d8c14bf7d76a841cfe904a6" } ================================================ FILE: crates/remote/.sqlx/query-4815234c108e45d450f433e5daca76218abdb441b9475ba916a39ab9e1341030.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE issues\n SET\n status_id = COALESCE($1, status_id),\n title = COALESCE($2, title),\n description = CASE WHEN $3 THEN $4 ELSE description END,\n priority = CASE WHEN $5 THEN $6 ELSE priority END,\n start_date = CASE WHEN $7 THEN $8 ELSE start_date END,\n target_date = CASE WHEN $9 THEN $10 ELSE target_date END,\n completed_at = CASE WHEN $11 THEN $12 ELSE completed_at END,\n sort_order = COALESCE($13, sort_order),\n parent_issue_id = CASE WHEN $14 THEN $15 ELSE parent_issue_id END,\n parent_issue_sort_order = CASE WHEN $16 THEN $17 ELSE parent_issue_sort_order END,\n extension_metadata = COALESCE($18, extension_metadata),\n updated_at = NOW()\n WHERE id = $19\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n issue_number AS \"issue_number!\",\n simple_id AS \"simple_id!\",\n status_id AS \"status_id!: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n priority AS \"priority: IssuePriority\",\n start_date AS \"start_date?: DateTime\",\n target_date AS \"target_date?: DateTime\",\n completed_at AS \"completed_at?: DateTime\",\n sort_order AS \"sort_order!\",\n parent_issue_id AS \"parent_issue_id?: Uuid\",\n parent_issue_sort_order AS \"parent_issue_sort_order?\",\n extension_metadata AS \"extension_metadata!: Value\",\n creator_user_id AS \"creator_user_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_number!", "type_info": "Int4" }, { "ordinal": 3, "name": "simple_id!", "type_info": "Varchar" }, { "ordinal": 4, "name": "status_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "title!", "type_info": "Varchar" }, { "ordinal": 6, "name": "description?", "type_info": "Text" }, { "ordinal": 7, "name": "priority: IssuePriority", "type_info": { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } } }, { "ordinal": 8, "name": "start_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "target_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "completed_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "sort_order!", "type_info": "Float8" }, { "ordinal": 12, "name": "parent_issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 13, "name": "parent_issue_sort_order?", "type_info": "Float8" }, { "ordinal": 14, "name": "extension_metadata!: Value", "type_info": "Jsonb" }, { "ordinal": 15, "name": "creator_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 16, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 17, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Varchar", "Bool", "Text", "Bool", { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } }, "Bool", "Timestamptz", "Bool", "Timestamptz", "Bool", "Timestamptz", "Float8", "Bool", "Uuid", "Bool", "Float8", "Jsonb", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true, true, true, true, false, true, true, false, true, false, false ] }, "hash": "4815234c108e45d450f433e5daca76218abdb441b9475ba916a39ab9e1341030" } ================================================ FILE: crates/remote/.sqlx/query-4b27e9774d71a851edc8c042e682037a35bd4cdffe22f3a13e1730f0d6712485.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n issue_prefix AS \"issue_prefix!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organizations\n WHERE slug = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "4b27e9774d71a851edc8c042e682037a35bd4cdffe22f3a13e1730f0d6712485" } ================================================ FILE: crates/remote/.sqlx/query-4cc8dc5f57a8398ef28942eab072784543333eac379d78c5843ca0c2203b69f5.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO pending_uploads (project_id, blob_path, hash, expires_at)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n hash AS \"hash!\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "hash!", "type_info": "Text" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "4cc8dc5f57a8398ef28942eab072784543333eac379d78c5843ca0c2203b69f5" } ================================================ FILE: crates/remote/.sqlx/query-4d963a12190ee1db657446ef451c5364f8f91153f7f1bb4e5abfd3f3ddbe0461.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO auth_sessions (user_id, refresh_token_id)\n VALUES ($1, $2)\n RETURNING\n id AS \"id!\",\n user_id AS \"user_id!: Uuid\",\n created_at AS \"created_at!\",\n last_used_at AS \"last_used_at?\",\n revoked_at AS \"revoked_at?\",\n refresh_token_id AS \"refresh_token_id?\",\n refresh_token_issued_at AS \"refresh_token_issued_at?\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "last_used_at?", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "revoked_at?", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "refresh_token_id?", "type_info": "Uuid" }, { "ordinal": 6, "name": "refresh_token_issued_at?", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, false, false, true, true, true, true ] }, "hash": "4d963a12190ee1db657446ef451c5364f8f91153f7f1bb4e5abfd3f3ddbe0461" } ================================================ FILE: crates/remote/.sqlx/query-4decb0554367c10f06a45f14291e5ba2a3e16aaf63bf1c34c2e8bc0c249fe4dd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT review_enabled\n FROM github_app_repositories\n WHERE installation_id = $1 AND github_repo_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "review_enabled", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Int8" ] }, "nullable": [ false ] }, "hash": "4decb0554367c10f06a45f14291e5ba2a3e16aaf63bf1c34c2e8bc0c249fe4dd" } ================================================ FILE: crates/remote/.sqlx/query-4e74faa43c070a492467104f59f81a8cb7e304593dd8cc12523b2c9052a48275.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE issue_comment_reactions\n SET\n emoji = COALESCE($1, emoji)\n WHERE id = $2\n RETURNING\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "comment_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "emoji!", "type_info": "Varchar" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Varchar", "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "4e74faa43c070a492467104f59f81a8cb7e304593dd8cc12523b2c9052a48275" } ================================================ FILE: crates/remote/.sqlx/query-4f80d17d6ca14600ec33d3660b8aa2efb385baf0384b6e666c3d25f0dad3c902.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_relationships WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "4f80d17d6ca14600ec33d3660b8aa2efb385baf0384b6e666c3d25f0dad3c902" } ================================================ FILE: crates/remote/.sqlx/query-4fc440b2735dfe8561c3f75440d8eaab32d1c31c994e17f319f52045bf96714f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n hash AS \"hash!\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at!: DateTime\"\n FROM pending_uploads\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "hash!", "type_info": "Text" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "4fc440b2735dfe8561c3f75440d8eaab32d1c31c994e17f319f52045bf96714f" } ================================================ FILE: crates/remote/.sqlx/query-51fe714966b7474d7f96cda8411b353e51efd935c929b689a8c33872d6a887b0.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO projects (id, organization_id, name, color, created_at, updated_at)\n SELECT gen_random_uuid(), organization_id, name, color, created_at, NOW()\n FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::timestamptz[])\n AS t(organization_id, name, color, created_at)\n RETURNING id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" } ], "parameters": { "Left": [ "UuidArray", "TextArray", "TextArray", "TimestamptzArray" ] }, "nullable": [ false ] }, "hash": "51fe714966b7474d7f96cda8411b353e51efd935c929b689a8c33872d6a887b0" } ================================================ FILE: crates/remote/.sqlx/query-547e9a424c4baa6d0a39299996fc8ee6abf88c2b6f687a17ec8216059de49596.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM pending_uploads WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "547e9a424c4baa6d0a39299996fc8ee6abf88c2b6f687a17ec8216059de49596" } ================================================ FILE: crates/remote/.sqlx/query-55f054b37280bfa43dbea79edd61ba969bacf776c0be43b608b5b0ca3f68c1fe.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_pending_installations\n WHERE expires_at < NOW()\n ", "describe": { "columns": [], "parameters": { "Left": [] }, "nullable": [] }, "hash": "55f054b37280bfa43dbea79edd61ba969bacf776c0be43b608b5b0ca3f68c1fe" } ================================================ FILE: crates/remote/.sqlx/query-56b1a366106974ec86702175bc3b4cad61f7437599082e142262169647df324d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\"\n FROM issue_followers\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "56b1a366106974ec86702175bc3b4cad61f7437599082e142262169647df324d" } ================================================ FILE: crates/remote/.sqlx/query-56d467122fa8b6599dc8821f65c2b191f378c9a76d3707d63d8cee1ef31fe4ba.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO oauth_handoffs (\n provider,\n state,\n return_to,\n app_challenge,\n expires_at\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "provider!", "type_info": "Text" }, { "ordinal": 2, "name": "state!", "type_info": "Text" }, { "ordinal": 3, "name": "return_to!", "type_info": "Text" }, { "ordinal": 4, "name": "app_challenge!", "type_info": "Text" }, { "ordinal": 5, "name": "app_code_hash?", "type_info": "Text" }, { "ordinal": 6, "name": "status!", "type_info": "Text" }, { "ordinal": 7, "name": "error_code?", "type_info": "Text" }, { "ordinal": 8, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "authorized_at?", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "redeemed_at?", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "user_id?", "type_info": "Uuid" }, { "ordinal": 12, "name": "session_id?", "type_info": "Uuid" }, { "ordinal": 13, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 14, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 15, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Text", "Text", "Text", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, true, false, true, false, true, true, true, true, true, false, false ] }, "hash": "56d467122fa8b6599dc8821f65c2b191f378c9a76d3707d63d8cee1ef31fe4ba" } ================================================ FILE: crates/remote/.sqlx/query-56d8fd993d1926824c84fff5b5a7f918f06e301ba4938075305eb575d310e891.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE issue_comments\n SET\n message = COALESCE($1, message),\n updated_at = $2\n WHERE id = $3\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n author_id AS \"author_id: Uuid\",\n parent_id AS \"parent_id: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "author_id: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "parent_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "message!", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Timestamptz", "Uuid" ] }, "nullable": [ false, false, true, true, false, false, false ] }, "hash": "56d8fd993d1926824c84fff5b5a7f918f06e301ba4938075305eb575d310e891" } ================================================ FILE: crates/remote/.sqlx/query-56efc697008a751a659452d95248636ce60c7f13fb2a3ef3f5440a7c795b13eb.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_comment_reactions WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "56efc697008a751a659452d95248636ce60c7f13fb2a3ef3f5440a7c795b13eb" } ================================================ FILE: crates/remote/.sqlx/query-574f50459071d9a400bad0c7623ab1618c6ae90b4a60adb8cb4a75628cb22c1c.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE github_app_installations\n SET suspended_at = NOW(), updated_at = NOW()\n WHERE github_installation_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8" ] }, "nullable": [] }, "hash": "574f50459071d9a400bad0c7623ab1618c6ae90b4a60adb8cb4a75628cb22c1c" } ================================================ FILE: crates/remote/.sqlx/query-577b1dc54aeefe702c74a56776544a391429b561b76d36d59673e410d5d78576.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_handoffs\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "provider!", "type_info": "Text" }, { "ordinal": 2, "name": "state!", "type_info": "Text" }, { "ordinal": 3, "name": "return_to!", "type_info": "Text" }, { "ordinal": 4, "name": "app_challenge!", "type_info": "Text" }, { "ordinal": 5, "name": "app_code_hash?", "type_info": "Text" }, { "ordinal": 6, "name": "status!", "type_info": "Text" }, { "ordinal": 7, "name": "error_code?", "type_info": "Text" }, { "ordinal": 8, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "authorized_at?", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "redeemed_at?", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "user_id?", "type_info": "Uuid" }, { "ordinal": 12, "name": "session_id?", "type_info": "Uuid" }, { "ordinal": 13, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 14, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 15, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, false, true, false, true, true, true, true, true, false, false ] }, "hash": "577b1dc54aeefe702c74a56776544a391429b561b76d36d59673e410d5d78576" } ================================================ FILE: crates/remote/.sqlx/query-58d7e8202ef0fb891303c761ae83a803459ffdda3c2a43ca3d6f74c0e3ecb34d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n related_issue_id AS \"related_issue_id!: Uuid\",\n relationship_type AS \"relationship_type!: IssueRelationshipType\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_relationships\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "related_issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "relationship_type!: IssueRelationshipType", "type_info": { "Custom": { "name": "issue_relationship_type", "kind": { "Enum": [ "blocking", "related", "has_duplicate" ] } } } }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "58d7e8202ef0fb891303c761ae83a803459ffdda3c2a43ca3d6f74c0e3ecb34d" } ================================================ FILE: crates/remote/.sqlx/query-5a652dd2a3d8bcbc8824584f8a1d9ccbb1fa56f54575b6c9dcd855a26de1edc5.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT p.pubname AS pubname, n.nspname AS schema_name, c.relname AS table_name\n FROM pg_publication_rel pr\n JOIN pg_publication p ON pr.prpubid = p.oid\n JOIN pg_class c ON pr.prrelid = c.oid\n JOIN pg_namespace n ON c.relnamespace = n.oid\n WHERE p.pubname = ANY($1)", "describe": { "columns": [ { "ordinal": 0, "name": "pubname", "type_info": "Name" }, { "ordinal": 1, "name": "schema_name", "type_info": "Name" }, { "ordinal": 2, "name": "table_name", "type_info": "Name" } ], "parameters": { "Left": [ "NameArray" ] }, "nullable": [ false, false, false ] }, "hash": "5a652dd2a3d8bcbc8824584f8a1d9ccbb1fa56f54575b6c9dcd855a26de1edc5" } ================================================ FILE: crates/remote/.sqlx/query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE organization_id = $1\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "invited_by_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "email!", "type_info": "Text" }, { "ordinal": 4, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 5, "name": "status!: InvitationStatus", "type_info": { "Custom": { "name": "invitation_status", "kind": { "Enum": [ "pending", "accepted", "declined", "expired" ] } } } }, { "ordinal": 6, "name": "token!", "type_info": "Text" }, { "ordinal": 7, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, false, false, false, false, false, false, false ] }, "hash": "5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8" } ================================================ FILE: crates/remote/.sqlx/query-5ce478f8221034468e5ea9ec66051e724d7054f8c62106795bccf9fd5366696d.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name)\n VALUES ($1, $2, $3)\n ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\n repo_full_name = EXCLUDED.repo_full_name\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8", "Text" ] }, "nullable": [] }, "hash": "5ce478f8221034468e5ea9ec66051e724d7054f8c62106795bccf9fd5366696d" } ================================================ FILE: crates/remote/.sqlx/query-5dae00eb6e3bef4d8ded1db51ad1252f6df335355b877f0dd64075f74c0018b8.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE oauth_accounts\n SET encrypted_provider_tokens = $3\n WHERE user_id = $1\n AND provider = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Text" ] }, "nullable": [] }, "hash": "5dae00eb6e3bef4d8ded1db51ad1252f6df335355b877f0dd64075f74c0018b8" } ================================================ FILE: crates/remote/.sqlx/query-5f8a332903cbf55aca62cf642bfca4e1815e2b168889f3a5983cb859c77a75b6.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n related_issue_id AS \"related_issue_id!: Uuid\",\n relationship_type AS \"relationship_type!: IssueRelationshipType\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_relationships\n WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "related_issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "relationship_type!: IssueRelationshipType", "type_info": { "Custom": { "name": "issue_relationship_type", "kind": { "Enum": [ "blocking", "related", "has_duplicate" ] } } } }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "5f8a332903cbf55aca62cf642bfca4e1815e2b168889f3a5983cb859c77a75b6" } ================================================ FILE: crates/remote/.sqlx/query-61245f2cee584d03acf4fd65dec00d22076134f726e5a5f4f13d1f4fc2060974.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n FROM project_statuses\n WHERE project_id = $1 AND LOWER(name) = LOWER($2)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "61245f2cee584d03acf4fd65dec00d22076134f726e5a5f4f13d1f4fc2060974" } ================================================ FILE: crates/remote/.sqlx/query-6205d4d925ce5c7ab8a91e109c807b458a668304ed6262c5afab4b85a227d119.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_pending_installations\n WHERE organization_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "6205d4d925ce5c7ab8a91e109c807b458a668304ed6262c5afab4b85a227d119" } ================================================ FILE: crates/remote/.sqlx/query-622e613f8a71f6dd4d110df061bff6ab4e46636ab60dd85dbccd9181d004de33.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n tag_id AS \"tag_id!: Uuid\"\n FROM issue_tags\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "tag_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "622e613f8a71f6dd4d110df061bff6ab4e46636ab60dd85dbccd9181d004de33" } ================================================ FILE: crates/remote/.sqlx/query-623c1d7933109030c4dbbf84d6028d1a7c94394906d1300c257c2e657925eb25.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_comment_reactions (id, comment_id, user_id, emoji, created_at)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "comment_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "emoji!", "type_info": "Varchar" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Varchar", "Timestamptz" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "623c1d7933109030c4dbbf84d6028d1a7c94394906d1300c257c2e657925eb25" } ================================================ FILE: crates/remote/.sqlx/query-62a28e66786692c5525ac4266bd3120d75ada4b85ed14f6815231c8691604e2f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n FROM project_statuses\n WHERE project_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "62a28e66786692c5525ac4266bd3120d75ada4b85ed14f6815231c8691604e2f" } ================================================ FILE: crates/remote/.sqlx/query-633bc2ca535b8b0078e81e188c734426421fe426dfb90697d025556cc8cb723f.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO organizations (name, slug, is_personal, issue_prefix)\n VALUES ($1, $2, TRUE, $3)\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n issue_prefix AS \"issue_prefix!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Text", "Varchar" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "633bc2ca535b8b0078e81e188c734426421fe426dfb90697d025556cc8cb723f" } ================================================ FILE: crates/remote/.sqlx/query-63ad252e1cd34aca9f819e457d0184c8df21cb4d2b1606ef84c3bdf5fc4457b0.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n assigned_at AS \"assigned_at!: DateTime\"\n FROM issue_assignees\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "assigned_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "63ad252e1cd34aca9f819e457d0184c8df21cb4d2b1606ef84c3bdf5fc4457b0" } ================================================ FILE: crates/remote/.sqlx/query-6412e3c9c929c588d924c1f899891f5d47f92d48b19f93823fb5a795d44a736a.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO workspaces (project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\n SELECT project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at\n FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[], $4::uuid[], $5::boolean[], $6::timestamptz[])\n AS t(project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at)\n RETURNING id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" } ], "parameters": { "Left": [ "UuidArray", "UuidArray", "UuidArray", "UuidArray", "BoolArray", "TimestamptzArray" ] }, "nullable": [ false ] }, "hash": "6412e3c9c929c588d924c1f899891f5d47f92d48b19f93823fb5a795d44a736a" } ================================================ FILE: crates/remote/.sqlx/query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE organization_invitations\n SET status = 'accepted'\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec" } ================================================ FILE: crates/remote/.sqlx/query-667708775c67d5b3ee9a55730434f37f9ae7a49ba89301999fbb1e20aef9bb42.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n blob_id AS \"blob_id!: Uuid\",\n issue_id AS \"issue_id?: Uuid\",\n comment_id AS \"comment_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at?: DateTime\"\n FROM attachments\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, true ] }, "hash": "667708775c67d5b3ee9a55730434f37f9ae7a49ba89301999fbb1e20aef9bb42" } ================================================ FILE: crates/remote/.sqlx/query-66eefd452f6ccbd5bc757154a1da211c8134075b7c9f42dacc4fecaedd1c8737.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n o.id AS \"id!: Uuid\",\n o.name AS \"name!\",\n o.slug AS \"slug!\",\n o.is_personal AS \"is_personal!\",\n o.issue_prefix AS \"issue_prefix!\",\n o.created_at AS \"created_at!\",\n o.updated_at AS \"updated_at!\",\n m.role AS \"user_role!: MemberRole\"\n FROM organizations o\n JOIN organization_member_metadata m ON m.organization_id = o.id\n WHERE m.user_id = $1\n ORDER BY o.created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" }, { "ordinal": 7, "name": "user_role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false, false ] }, "hash": "66eefd452f6ccbd5bc757154a1da211c8134075b7c9f42dacc4fecaedd1c8737" } ================================================ FILE: crates/remote/.sqlx/query-68422b179dc361337c65a6bd1aa455a961708b97a673d84f7af64cd252cbfdf3.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET revoked_at = NOW()\n WHERE user_id = $1\n AND revoked_at IS NULL\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "68422b179dc361337c65a6bd1aa455a961708b97a673d84f7af64cd252cbfdf3" } ================================================ FILE: crates/remote/.sqlx/query-68494b64181d1ca4293962abfcf0af30e5b4d6947dd4e9509bfc21d8fe4b93d5.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_repositories\n WHERE installation_id = $1 AND NOT (github_repo_id = ANY($2))\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8Array" ] }, "nullable": [] }, "hash": "68494b64181d1ca4293962abfcf0af30e5b4d6947dd4e9509bfc21d8fe4b93d5" } ================================================ FILE: crates/remote/.sqlx/query-690c16c206895598016a784884f1a764f4a921232df68cc046495ff4f39827ec.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n FROM project_statuses\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "690c16c206895598016a784884f1a764f4a921232df68cc046495ff4f39827ec" } ================================================ FILE: crates/remote/.sqlx/query-6a1bfdce77c93b841ff0c3b533a71e6d9c9d333659de1b12ffbe462ae0123bd5.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE github_app_repositories\n SET review_enabled = $2\n WHERE installation_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Bool" ] }, "nullable": [] }, "hash": "6a1bfdce77c93b841ff0c3b533a71e6d9c9d333659de1b12ffbe462ae0123bd5" } ================================================ FILE: crates/remote/.sqlx/query-6be91bb87b8d2b28f600bf4f59224281d676281278f6c6bf266a1aa3a91d44fd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n tag_id AS \"tag_id!: Uuid\"\n FROM issue_tags\n WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "tag_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "6be91bb87b8d2b28f600bf4f59224281d676281278f6c6bf266a1aa3a91d44fd" } ================================================ FILE: crates/remote/.sqlx/query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT organization_id\n FROM projects\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c" } ================================================ FILE: crates/remote/.sqlx/query-6fae9f0d59fa5fb6b03ba068d1b50e82aa1b91fa2abe782bdbddd4ccbbd7971c.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM workspaces WHERE \"owner_user_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "6fae9f0d59fa5fb6b03ba068d1b50e82aa1b91fa2abe782bdbddd4ccbbd7971c" } ================================================ FILE: crates/remote/.sqlx/query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT EXISTS(\n SELECT 1 FROM reviews\n WHERE pr_owner = $1\n AND pr_repo = $2\n AND pr_number = $3\n AND status = 'pending'\n AND deleted_at IS NULL\n ) as \"exists!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "exists!", "type_info": "Bool" } ], "parameters": { "Left": [ "Text", "Text", "Int4" ] }, "nullable": [ null ] }, "hash": "7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990" } ================================================ FILE: crates/remote/.sqlx/query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO organization_member_metadata (organization_id, user_id, role)\n VALUES ($1, $2, $3)\n ON CONFLICT (organization_id, user_id) DO UPDATE\n SET role = EXCLUDED.role\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } ] }, "nullable": [] }, "hash": "75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb" } ================================================ FILE: crates/remote/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET last_used_at = date_trunc('day', NOW())\n WHERE id = $1\n AND (\n last_used_at IS NULL\n OR last_used_at < date_trunc('day', NOW())\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e" } ================================================ FILE: crates/remote/.sqlx/query-79dc2aa6cb26c21530ac05b84ec58aff9b042724bda846aadd9bf1b1a3a53791.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE reviews\n SET status = 'completed'\n WHERE id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "79dc2aa6cb26c21530ac05b84ec58aff9b042724bda846aadd9bf1b1a3a53791" } ================================================ FILE: crates/remote/.sqlx/query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT user_id\n FROM organization_member_metadata\n WHERE organization_id = $1 AND role = 'admin'\n FOR UPDATE\n ", "describe": { "columns": [ { "ordinal": 0, "name": "user_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838" } ================================================ FILE: crates/remote/.sqlx/query-7a96ad78e02ebdb1f6d29d941a3c393b128a7165123b63c455df2c2581995e35.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM users\n WHERE id IN (SELECT user_id FROM organization_member_metadata WHERE organization_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email!", "type_info": "Text" }, { "ordinal": 2, "name": "first_name?", "type_info": "Text" }, { "ordinal": 3, "name": "last_name?", "type_info": "Text" }, { "ordinal": 4, "name": "username?", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, true, false, false ] }, "hash": "7a96ad78e02ebdb1f6d29d941a3c393b128a7165123b63c455df2c2581995e35" } ================================================ FILE: crates/remote/.sqlx/query-7c54f2956d1c6f0912da45e40590e2bfbb1e5c24c374c2d68ca5b692c87cf26f.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n url AS \"url!: String\",\n number AS \"number!: i32\",\n status AS \"status!: PullRequestStatus\",\n merged_at AS \"merged_at: DateTime\",\n merge_commit_sha AS \"merge_commit_sha: String\",\n target_branch_name AS \"target_branch_name!: String\",\n issue_id AS \"issue_id!: Uuid\",\n workspace_id AS \"workspace_id: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM pull_requests\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "url!: String", "type_info": "Text" }, { "ordinal": 2, "name": "number!: i32", "type_info": "Int4" }, { "ordinal": 3, "name": "status!: PullRequestStatus", "type_info": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } }, { "ordinal": 4, "name": "merged_at: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "merge_commit_sha: String", "type_info": "Varchar" }, { "ordinal": 6, "name": "target_branch_name!: String", "type_info": "Text" }, { "ordinal": 7, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 8, "name": "workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 9, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, true, true, false, false, true, false, false ] }, "hash": "7c54f2956d1c6f0912da45e40590e2bfbb1e5c24c374c2d68ca5b692c87cf26f" } ================================================ FILE: crates/remote/.sqlx/query-7d628ce544ed41baf2d0cdc0c95f35ac324474564b8cbb6735c9a7fc6aff75fa.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE projects\n SET\n name = COALESCE($1, name),\n color = COALESCE($2, color),\n sort_order = COALESCE($3, sort_order),\n updated_at = $4\n WHERE id = $5\n RETURNING\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Text" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text", "Varchar", "Int4", "Timestamptz", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "7d628ce544ed41baf2d0cdc0c95f35ac324474564b8cbb6735c9a7fc6aff75fa" } ================================================ FILE: crates/remote/.sqlx/query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO organization_invitations (\n organization_id, invited_by_user_id, email, role, token, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "invited_by_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "email!", "type_info": "Text" }, { "ordinal": 4, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 5, "name": "status!: InvitationStatus", "type_info": { "Custom": { "name": "invitation_status", "kind": { "Enum": [ "pending", "accepted", "declined", "expired" ] } } } }, { "ordinal": 6, "name": "token!", "type_info": "Text" }, { "ordinal": 7, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Text", { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } }, "Text", "Timestamptz" ] }, "nullable": [ false, false, true, false, false, false, false, false, false, false ] }, "hash": "7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17" } ================================================ FILE: crates/remote/.sqlx/query-7f100e4420b2b8c086eac892d13f0ed114a5667b9c26fe7d99dcff1f4b3b1a9f.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issues WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "7f100e4420b2b8c086eac892d13f0ed114a5667b9c26fe7d99dcff1f4b3b1a9f" } ================================================ FILE: crates/remote/.sqlx/query-7fb263a325db6e402761a9d0643561b134deda610f7f163d38c20625a4fdd048.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_comment_reactions\n WHERE comment_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "comment_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "emoji!", "type_info": "Varchar" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "7fb263a325db6e402761a9d0643561b134deda610f7f163d38c20625a4fdd048" } ================================================ FILE: crates/remote/.sqlx/query-80c3a6879ba2142e78a397340501ac402808707724c58a67db7c7bb9040a7cb9.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n assigned_at AS \"assigned_at!: DateTime\"\n FROM issue_assignees\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "assigned_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "80c3a6879ba2142e78a397340501ac402808707724c58a67db7c7bb9040a7cb9" } ================================================ FILE: crates/remote/.sqlx/query-8123b99c8d0df1c3a39ae0b2e02b8f95e438dcaa7f85e4ad37a069d962ae2e39.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n user_id,\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n FROM notifications\n WHERE user_id = $1\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true, false, true, false ] }, "hash": "8123b99c8d0df1c3a39ae0b2e02b8f95e438dcaa7f85e4ad37a069d962ae2e39" } ================================================ FILE: crates/remote/.sqlx/query-823f54d7b4eb060b1c5eb4e45143e668286ae6716705e55bb4f4f0f89a5b4117.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM workspaces\n WHERE local_workspace_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "823f54d7b4eb060b1c5eb4e45143e668286ae6716705e55bb4f4f0f89a5b4117" } ================================================ FILE: crates/remote/.sqlx/query-82dcc3cd88256066ad91785afe686ec03090ea549029ba2c701cdfa2c1501f0d.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO workspaces (project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n owner_user_id AS \"owner_user_id!: Uuid\",\n issue_id AS \"issue_id: Uuid\",\n local_workspace_id AS \"local_workspace_id: Uuid\",\n name AS \"name: String\",\n archived AS \"archived!: bool\",\n files_changed AS \"files_changed: i32\",\n lines_added AS \"lines_added: i32\",\n lines_removed AS \"lines_removed: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "owner_user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "issue_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "local_workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "name: String", "type_info": "Text" }, { "ordinal": 6, "name": "archived!: bool", "type_info": "Bool" }, { "ordinal": 7, "name": "files_changed: i32", "type_info": "Int4" }, { "ordinal": 8, "name": "lines_added: i32", "type_info": "Int4" }, { "ordinal": 9, "name": "lines_removed: i32", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Uuid", "Text", "Bool", "Int4", "Int4", "Int4" ] }, "nullable": [ false, false, false, true, true, true, false, true, true, true, false, false ] }, "hash": "82dcc3cd88256066ad91785afe686ec03090ea549029ba2c701cdfa2c1501f0d" } ================================================ FILE: crates/remote/.sqlx/query-830e7650bdeccf581f260646182b3b5af903927702022ba1a4293d9d8627f727.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH existing AS (\n SELECT id FROM notifications\n WHERE user_id = $3\n AND notification_type = $4\n AND issue_id IS NOT DISTINCT FROM $6\n AND comment_id IS NOT DISTINCT FROM $7\n AND created_at > NOW() - INTERVAL '1 minute'\n ORDER BY created_at DESC\n LIMIT 1\n ),\n updated AS (\n UPDATE notifications\n SET payload = $5,\n seen = FALSE,\n dismissed_at = NULL,\n created_at = $8\n WHERE id = (SELECT id FROM existing)\n RETURNING\n id,\n organization_id,\n user_id,\n notification_type,\n payload,\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n ),\n inserted AS (\n INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8\n WHERE NOT EXISTS (SELECT 1 FROM existing)\n RETURNING\n id,\n organization_id,\n user_id,\n notification_type,\n payload,\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n )\n SELECT\n id as \"id!\",\n organization_id as \"organization_id!\",\n user_id as \"user_id!\",\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen as \"seen!\",\n dismissed_at,\n created_at as \"created_at!\"\n FROM updated\n UNION ALL\n SELECT\n id as \"id!\",\n organization_id as \"organization_id!\",\n user_id as \"user_id!\",\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen as \"seen!\",\n dismissed_at,\n created_at as \"created_at!\"\n FROM inserted\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen!", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } }, "Jsonb", "Uuid", "Uuid", "Timestamptz" ] }, "nullable": [ null, null, null, null, null, null, null, null, null, null ] }, "hash": "830e7650bdeccf581f260646182b3b5af903927702022ba1a4293d9d8627f727" } ================================================ FILE: crates/remote/.sqlx/query-83edd4a9b106ad4dfda19ed983d27aee591e50a5a5f4774dbe6d68265da0c6de.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*) AS \"count!\"\n FROM attachments\n WHERE blob_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count!", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "83edd4a9b106ad4dfda19ed983d27aee591e50a5a5f4774dbe6d68265da0c6de" } ================================================ FILE: crates/remote/.sqlx/query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE organization_member_metadata\n SET role = $3\n WHERE organization_id = $1 AND user_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid", { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } ] }, "nullable": [] }, "hash": "862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4" } ================================================ FILE: crates/remote/.sqlx/query-864ea9a40e219fdf04230331e225d699677200d5ccd3d4e12842060a657bd8ea.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM projects WHERE \"organization_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "864ea9a40e219fdf04230331e225d699677200d5ccd3d4e12842060a657bd8ea" } ================================================ FILE: crates/remote/.sqlx/query-86d32fea56d89959413ec714af2decbfd0b58a60ab4833cb300f15eac9061ff7.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM project_statuses WHERE \"project_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "86d32fea56d89959413ec714af2decbfd0b58a60ab4833cb300f15eac9061ff7" } ================================================ FILE: crates/remote/.sqlx/query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\"\n FROM users\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "first_name?", "type_info": "Text" }, { "ordinal": 2, "name": "last_name?", "type_info": "Text" }, { "ordinal": 3, "name": "username?", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, true, true, true ] }, "hash": "8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c" } ================================================ FILE: crates/remote/.sqlx/query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO tags (id, project_id, name, color)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Varchar", "Varchar" ] }, "nullable": [ false, false, false, false ] }, "hash": "877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c" } ================================================ FILE: crates/remote/.sqlx/query-878adca3c3dc2383f7dd86e19026f9aa18d6b2ac20a46a97630a43f9c5ee99eb.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_tags WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "878adca3c3dc2383f7dd86e19026f9aa18d6b2ac20a46a97630a43f9c5ee99eb" } ================================================ FILE: crates/remote/.sqlx/query-883490bd163237d721488136ba8efbe81f42357213817ce1efe61e6036184b3e.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n FROM issue_comment_reactions\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "comment_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "emoji!", "type_info": "Varchar" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false ] }, "hash": "883490bd163237d721488136ba8efbe81f42357213817ce1efe61e6036184b3e" } ================================================ FILE: crates/remote/.sqlx/query-88ac4f15091a552f40c752a56d2894bb4f46c5eaeff9eec813e2d3c032de3e82.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n url AS \"url!: String\",\n number AS \"number!: i32\",\n status AS \"status!: PullRequestStatus\",\n merged_at AS \"merged_at: DateTime\",\n merge_commit_sha AS \"merge_commit_sha: String\",\n target_branch_name AS \"target_branch_name!: String\",\n issue_id AS \"issue_id!: Uuid\",\n workspace_id AS \"workspace_id: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM pull_requests\n WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "url!: String", "type_info": "Text" }, { "ordinal": 2, "name": "number!: i32", "type_info": "Int4" }, { "ordinal": 3, "name": "status!: PullRequestStatus", "type_info": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } }, { "ordinal": 4, "name": "merged_at: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "merge_commit_sha: String", "type_info": "Varchar" }, { "ordinal": 6, "name": "target_branch_name!: String", "type_info": "Text" }, { "ordinal": 7, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 8, "name": "workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 9, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, true, true, false, false, true, false, false ] }, "hash": "88ac4f15091a552f40c752a56d2894bb4f46c5eaeff9eec813e2d3c032de3e82" } ================================================ FILE: crates/remote/.sqlx/query-8b2002931058f7e268604b9ad4f2a12ec6388fa66e20f8d3cc0f70c10e3d43ea.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n issue_number AS \"issue_number!\",\n simple_id AS \"simple_id!\",\n status_id AS \"status_id!: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n priority AS \"priority: IssuePriority\",\n start_date AS \"start_date?: DateTime\",\n target_date AS \"target_date?: DateTime\",\n completed_at AS \"completed_at?: DateTime\",\n sort_order AS \"sort_order!\",\n parent_issue_id AS \"parent_issue_id?: Uuid\",\n parent_issue_sort_order AS \"parent_issue_sort_order?\",\n extension_metadata AS \"extension_metadata!: Value\",\n creator_user_id AS \"creator_user_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM issues\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_number!", "type_info": "Int4" }, { "ordinal": 3, "name": "simple_id!", "type_info": "Varchar" }, { "ordinal": 4, "name": "status_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "title!", "type_info": "Varchar" }, { "ordinal": 6, "name": "description?", "type_info": "Text" }, { "ordinal": 7, "name": "priority: IssuePriority", "type_info": { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } } }, { "ordinal": 8, "name": "start_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "target_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "completed_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "sort_order!", "type_info": "Float8" }, { "ordinal": 12, "name": "parent_issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 13, "name": "parent_issue_sort_order?", "type_info": "Float8" }, { "ordinal": 14, "name": "extension_metadata!: Value", "type_info": "Jsonb" }, { "ordinal": 15, "name": "creator_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 16, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 17, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true, true, true, true, false, true, true, false, true, false, false ] }, "hash": "8b2002931058f7e268604b9ad4f2a12ec6388fa66e20f8d3cc0f70c10e3d43ea" } ================================================ FILE: crates/remote/.sqlx/query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE oauth_handoffs\n SET\n status = $2,\n error_code = $3\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Text", "Text" ] }, "nullable": [] }, "hash": "8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c" } ================================================ FILE: crates/remote/.sqlx/query-8e32d5bf86d112e2f4a16f622bd95c8f728946f01e1a994a9c66b0fac6e3ae52.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason)\n SELECT refresh_token_id, user_id, 'reuse_of_revoked_token'\n FROM auth_sessions\n WHERE user_id = $1\n AND refresh_token_id IS NOT NULL\n ON CONFLICT (token_id) DO NOTHING\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "8e32d5bf86d112e2f4a16f622bd95c8f728946f01e1a994a9c66b0fac6e3ae52" } ================================================ FILE: crates/remote/.sqlx/query-8e96696a873dbbd175f1d73eb03773beab823476f6d5712c02633dbc6efa0159.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE organizations\n SET name = $2\n WHERE id = $1\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n issue_prefix AS \"issue_prefix!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "8e96696a873dbbd175f1d73eb03773beab823476f6d5712c02633dbc6efa0159" } ================================================ FILE: crates/remote/.sqlx/query-8e9d6c188fe09693d027d408b15792cbbebc72d2dd5bd4e2de12ef533a073f75.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name, review_enabled)\n VALUES ($1, $2, $3, true)\n ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET\n repo_full_name = EXCLUDED.repo_full_name\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8", "Text" ] }, "nullable": [] }, "hash": "8e9d6c188fe09693d027d408b15792cbbebc72d2dd5bd4e2de12ef533a073f75" } ================================================ FILE: crates/remote/.sqlx/query-8fc5f7e1920e9d43034aeaacb0a00739e0ee3cd00d06a692beac0f0fb2324ac8.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO reviews (id, gh_pr_url, claude_code_session_id, ip_address, r2_path, email, pr_title)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING\n id,\n gh_pr_url,\n claude_code_session_id,\n ip_address AS \"ip_address: IpNetwork\",\n review_cache,\n last_viewed_at,\n r2_path,\n deleted_at,\n created_at,\n email,\n pr_title,\n status,\n github_installation_id,\n pr_owner,\n pr_repo,\n pr_number\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "gh_pr_url", "type_info": "Text" }, { "ordinal": 2, "name": "claude_code_session_id", "type_info": "Text" }, { "ordinal": 3, "name": "ip_address: IpNetwork", "type_info": "Inet" }, { "ordinal": 4, "name": "review_cache", "type_info": "Jsonb" }, { "ordinal": 5, "name": "last_viewed_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "r2_path", "type_info": "Text" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "email", "type_info": "Text" }, { "ordinal": 10, "name": "pr_title", "type_info": "Text" }, { "ordinal": 11, "name": "status", "type_info": "Text" }, { "ordinal": 12, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 13, "name": "pr_owner", "type_info": "Text" }, { "ordinal": 14, "name": "pr_repo", "type_info": "Text" }, { "ordinal": 15, "name": "pr_number", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Inet", "Text", "Text", "Text" ] }, "nullable": [ false, false, true, true, true, true, false, true, false, true, false, false, true, true, true, true ] }, "hash": "8fc5f7e1920e9d43034aeaacb0a00739e0ee3cd00d06a692beac0f0fb2324ac8" } ================================================ FILE: crates/remote/.sqlx/query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM organization_invitations\n WHERE id = $1 AND organization_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41" } ================================================ FILE: crates/remote/.sqlx/query-91776c42e46c2b2a3909baccf21dd83e2e1c88e592e94699a7a286fd396f2812.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE attachments a\n SET issue_id = $1, expires_at = NULL\n FROM blobs b\n WHERE a.blob_id = b.id\n AND a.id = ANY($2)\n AND a.issue_id IS NULL\n AND a.comment_id IS NULL\n RETURNING\n a.id AS \"id!: Uuid\",\n a.blob_id AS \"blob_id!: Uuid\",\n a.issue_id AS \"issue_id?: Uuid\",\n a.comment_id AS \"comment_id?: Uuid\",\n a.created_at AS \"created_at!: DateTime\",\n a.expires_at AS \"expires_at?: DateTime\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 7, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 8, "name": "original_name!", "type_info": "Text" }, { "ordinal": 9, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 10, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 11, "name": "hash!", "type_info": "Text" }, { "ordinal": 12, "name": "width?", "type_info": "Int4" }, { "ordinal": 13, "name": "height?", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "UuidArray" ] }, "nullable": [ false, false, true, true, false, true, false, true, false, true, false, false, true, true ] }, "hash": "91776c42e46c2b2a3909baccf21dd83e2e1c88e592e94699a7a286fd396f2812" } ================================================ FILE: crates/remote/.sqlx/query-92768b10d8dcb0593c1c5558d847aae11301163ffc053e89f08cae48a94753a0.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM organization_member_metadata WHERE \"organization_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "92768b10d8dcb0593c1c5558d847aae11301163ffc053e89f08cae48a94753a0" } ================================================ FILE: crates/remote/.sqlx/query-9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n user_id AS \"user_id!: Uuid\",\n created_at AS \"created_at!\",\n last_used_at AS \"last_used_at?\",\n revoked_at AS \"revoked_at?\",\n refresh_token_id AS \"refresh_token_id?\",\n refresh_token_issued_at AS \"refresh_token_issued_at?\"\n FROM auth_sessions\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "last_used_at?", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "revoked_at?", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "refresh_token_id?", "type_info": "Uuid" }, { "ordinal": 6, "name": "refresh_token_issued_at?", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, true ] }, "hash": "9459cf92b30943acb79f0e0f2e9421be83ce9e50e39f6b1e435b92ff70907264" } ================================================ FILE: crates/remote/.sqlx/query-95427f2ba8293a8aa51366aad80129a3cfdcd1b3ec4dc8298d3aa7d0c5419191.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_handoffs\n WHERE state = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "provider!", "type_info": "Text" }, { "ordinal": 2, "name": "state!", "type_info": "Text" }, { "ordinal": 3, "name": "return_to!", "type_info": "Text" }, { "ordinal": 4, "name": "app_challenge!", "type_info": "Text" }, { "ordinal": 5, "name": "app_code_hash?", "type_info": "Text" }, { "ordinal": 6, "name": "status!", "type_info": "Text" }, { "ordinal": 7, "name": "error_code?", "type_info": "Text" }, { "ordinal": 8, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "authorized_at?", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "redeemed_at?", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "user_id?", "type_info": "Uuid" }, { "ordinal": 12, "name": "session_id?", "type_info": "Uuid" }, { "ordinal": 13, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 14, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 15, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, false, false, true, false, true, false, true, true, true, true, true, false, false ] }, "hash": "95427f2ba8293a8aa51366aad80129a3cfdcd1b3ec4dc8298d3aa7d0c5419191" } ================================================ FILE: crates/remote/.sqlx/query-9889a5e2b2b849138e5af7bb649c9833cfa4fbc45c3bed269d25a8ada30634e4.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_pending_installations\n WHERE state_token = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text" ] }, "nullable": [] }, "hash": "9889a5e2b2b849138e5af7bb649c9833cfa4fbc45c3bed269d25a8ada30634e4" } ================================================ FILE: crates/remote/.sqlx/query-9b2762d25c773099f99e6ae65ccefc16ac367d725df8ebb7983420aa0fce4149.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM pending_uploads\n WHERE expires_at < NOW()\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n hash AS \"hash!\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "hash!", "type_info": "Text" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "9b2762d25c773099f99e6ae65ccefc16ac367d725df8ebb7983420aa0fce4149" } ================================================ FILE: crates/remote/.sqlx/query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM project_statuses WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569" } ================================================ FILE: crates/remote/.sqlx/query-a1431ca78db627fef0eca6f573b34d65510e9333765126cbd80c943046dfaea8.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE auth_sessions\n SET refresh_token_id = $2,\n refresh_token_issued_at = NOW()\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "a1431ca78db627fef0eca6f573b34d65510e9333765126cbd80c943046dfaea8" } ================================================ FILE: crates/remote/.sqlx/query-a4f5f53d0b9882e4e8147be7b618cb3dd18d0b5c74f4fd4faf13b4be6c6704ad.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n tag_id AS \"tag_id!: Uuid\"\n FROM issue_tags\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "tag_id!: Uuid", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false ] }, "hash": "a4f5f53d0b9882e4e8147be7b618cb3dd18d0b5c74f4fd4faf13b4be6c6704ad" } ================================================ FILE: crates/remote/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM projects WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314" } ================================================ FILE: crates/remote/.sqlx/query-a5d10a37114cf01163a023d70212d17b963a27528089dca9d8fe8503335ad14b.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO tags (id, project_id, name, color)\n SELECT gen_random_uuid(), $1, name, color\n FROM UNNEST($2::text[], $3::text[]) AS t(name, color)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" } ], "parameters": { "Left": [ "Uuid", "TextArray", "TextArray" ] }, "nullable": [ false, false, false, false ] }, "hash": "a5d10a37114cf01163a023d70212d17b963a27528089dca9d8fe8503335ad14b" } ================================================ FILE: crates/remote/.sqlx/query-a5d1aeb3ce62a3f286a2a4bddc38c3f5caf2eb556236b561b68483b17dc24cfd.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n i.id AS \"id!: Uuid\",\n i.project_id AS \"project_id!: Uuid\",\n i.issue_number AS \"issue_number!\",\n i.simple_id AS \"simple_id!\",\n i.status_id AS \"status_id!: Uuid\",\n i.title AS \"title!\",\n i.description AS \"description?\",\n i.priority AS \"priority: IssuePriority\",\n i.start_date AS \"start_date?: DateTime\",\n i.target_date AS \"target_date?: DateTime\",\n i.completed_at AS \"completed_at?: DateTime\",\n i.sort_order AS \"sort_order!\",\n i.parent_issue_id AS \"parent_issue_id?: Uuid\",\n i.parent_issue_sort_order AS \"parent_issue_sort_order?\",\n i.extension_metadata AS \"extension_metadata!: Value\",\n i.creator_user_id AS \"creator_user_id?: Uuid\",\n i.created_at AS \"created_at!: DateTime\",\n i.updated_at AS \"updated_at!: DateTime\"\n FROM issues i\n LEFT JOIN project_statuses ps ON ps.id = i.status_id\n WHERE i.project_id = $1\n AND ($2::uuid IS NULL OR i.status_id = $2)\n AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\n AND ($4::issue_priority IS NULL OR i.priority = $4)\n AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\n AND (\n $6::text IS NULL\n OR i.title ILIKE $6 ESCAPE '\\'\n OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\'\n )\n AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\')\n AND (\n $8::uuid IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_assignees ia\n WHERE ia.issue_id = i.id AND ia.user_id = $8\n )\n )\n AND (\n $9::uuid IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_tags it\n WHERE it.issue_id = i.id AND it.tag_id = $9\n )\n )\n AND (\n $10::uuid[] IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_tags it\n WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\n )\n )\n ORDER BY\n CASE\n WHEN $11 = 'sort_order' AND $12 = 'asc' THEN ps.sort_order\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'sort_order' AND $12 = 'desc' THEN ps.sort_order\n END DESC NULLS LAST,\n CASE\n WHEN $11 = 'sort_order' AND $12 = 'asc' THEN i.sort_order\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'sort_order' AND $12 = 'desc' THEN i.sort_order\n END DESC NULLS LAST,\n CASE\n WHEN $11 = 'priority' AND $12 = 'asc' THEN i.priority\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'priority' AND $12 = 'desc' THEN i.priority\n END DESC NULLS FIRST,\n CASE\n WHEN $11 = 'created_at' AND $12 = 'asc' THEN i.created_at\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'created_at' AND $12 = 'desc' THEN i.created_at\n END DESC NULLS LAST,\n CASE\n WHEN $11 = 'updated_at' AND $12 = 'asc' THEN i.updated_at\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'updated_at' AND $12 = 'desc' THEN i.updated_at\n END DESC NULLS LAST,\n CASE\n WHEN $11 = 'title' AND $12 = 'asc' THEN i.title\n END ASC NULLS LAST,\n CASE\n WHEN $11 = 'title' AND $12 = 'desc' THEN i.title\n END DESC NULLS LAST,\n i.issue_number ASC\n LIMIT $13\n OFFSET $14\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_number!", "type_info": "Int4" }, { "ordinal": 3, "name": "simple_id!", "type_info": "Varchar" }, { "ordinal": 4, "name": "status_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "title!", "type_info": "Varchar" }, { "ordinal": 6, "name": "description?", "type_info": "Text" }, { "ordinal": 7, "name": "priority: IssuePriority", "type_info": { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } } }, { "ordinal": 8, "name": "start_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "target_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "completed_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "sort_order!", "type_info": "Float8" }, { "ordinal": 12, "name": "parent_issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 13, "name": "parent_issue_sort_order?", "type_info": "Float8" }, { "ordinal": 14, "name": "extension_metadata!: Value", "type_info": "Jsonb" }, { "ordinal": 15, "name": "creator_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 16, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 17, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "UuidArray", { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } }, "Uuid", "Text", "Text", "Uuid", "Uuid", "UuidArray", "Text", "Text", "Int8", "Int8" ] }, "nullable": [ false, false, false, false, false, false, true, true, true, true, true, false, true, true, false, true, false, false ] }, "hash": "a5d1aeb3ce62a3f286a2a4bddc38c3f5caf2eb556236b561b68483b17dc24cfd" } ================================================ FILE: crates/remote/.sqlx/query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n FROM tags\n WHERE project_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b" } ================================================ FILE: crates/remote/.sqlx/query-aa31348f22b24c16e1d1365c2508ad7b6c155ef2a50cabd80b59e297001dd93a.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO attachments (id, blob_id, issue_id, comment_id, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id AS \"id!: Uuid\",\n blob_id AS \"blob_id!: Uuid\",\n issue_id AS \"issue_id?: Uuid\",\n comment_id AS \"comment_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at?: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Uuid", "Timestamptz" ] }, "nullable": [ false, false, true, true, false, true ] }, "hash": "aa31348f22b24c16e1d1365c2508ad7b6c155ef2a50cabd80b59e297001dd93a" } ================================================ FILE: crates/remote/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM users\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email!", "type_info": "Text" }, { "ordinal": 2, "name": "first_name?", "type_info": "Text" }, { "ordinal": 3, "name": "last_name?", "type_info": "Text" }, { "ordinal": 4, "name": "username?", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, true, false, false ] }, "hash": "b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d" } ================================================ FILE: crates/remote/.sqlx/query-b33110a056cf1ed2bb527aa975f8099d52ac0c9482cdf695a980fad0223ea136.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issue_comments (id, issue_id, author_id, parent_id, message, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n author_id AS \"author_id: Uuid\",\n parent_id AS \"parent_id: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "author_id: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "parent_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "message!", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Uuid", "Text", "Timestamptz", "Timestamptz" ] }, "nullable": [ false, false, true, true, false, false, false ] }, "hash": "b33110a056cf1ed2bb527aa975f8099d52ac0c9482cdf695a980fad0223ea136" } ================================================ FILE: crates/remote/.sqlx/query-b5a2ccf794217a408e9ffb663183af1ba203d6d2274e9562a9e3aa938ea6d71b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM projects\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Text" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "b5a2ccf794217a408e9ffb663183af1ba203d6d2274e9562a9e3aa938ea6d71b" } ================================================ FILE: crates/remote/.sqlx/query-b97175fb9a4f5a7379119da3760be7efc1ba2bf95bd5d3e6725f4f98aa7d955a.json ================================================ { "db_name": "PostgreSQL", "query": "\n WITH s AS (\n SELECT\n BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin\n FROM organization_member_metadata\n WHERE organization_id = $1\n )\n DELETE FROM organizations o\n USING s\n WHERE o.id = $1\n AND s.is_admin = true\n RETURNING o.id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false ] }, "hash": "b97175fb9a4f5a7379119da3760be7efc1ba2bf95bd5d3e6725f4f98aa7d955a" } ================================================ FILE: crates/remote/.sqlx/query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE token = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "invited_by_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "email!", "type_info": "Text" }, { "ordinal": 4, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 5, "name": "status!: InvitationStatus", "type_info": { "Custom": { "name": "invitation_status", "kind": { "Enum": [ "pending", "accepted", "declined", "expired" ] } } } }, { "ordinal": 6, "name": "token!", "type_info": "Text" }, { "ordinal": 7, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, true, false, false, false, false, false, false, false ] }, "hash": "b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee" } ================================================ FILE: crates/remote/.sqlx/query-baa0922dd5a1e99794f480c483124402f5e1cd014e87919a72d468cd9762ec49.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE tags\n SET\n name = COALESCE($1, name),\n color = COALESCE($2, color)\n WHERE id = $3\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" } ], "parameters": { "Left": [ "Varchar", "Varchar", "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "baa0922dd5a1e99794f480c483124402f5e1cd014e87919a72d468cd9762ec49" } ================================================ FILE: crates/remote/.sqlx/query-bc0e36b956903c2ace672e6c52516598ec5f2b0288dcb935ef4d1bc694dacf0d.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issues WHERE \"project_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "bc0e36b956903c2ace672e6c52516598ec5f2b0288dcb935ef4d1bc694dacf0d" } ================================================ FILE: crates/remote/.sqlx/query-bd632f11a197d6a17fcdf3e757283a64d281a931aaacd1ed6e4b73f18f1b6a2f.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_installations\n WHERE organization_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "bd632f11a197d6a17fcdf3e757283a64d281a931aaacd1ed6e4b73f18f1b6a2f" } ================================================ FILE: crates/remote/.sqlx/query-bdbdee30d4f94a94f3d449aaea132512d8334a6a2636f898facd4e916a683f5e.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE attachments a\n SET comment_id = $1, expires_at = NULL\n FROM blobs b\n WHERE a.blob_id = b.id\n AND a.id = ANY($2)\n AND a.issue_id IS NULL\n AND a.comment_id IS NULL\n RETURNING\n a.id AS \"id!: Uuid\",\n a.blob_id AS \"blob_id!: Uuid\",\n a.issue_id AS \"issue_id?: Uuid\",\n a.comment_id AS \"comment_id?: Uuid\",\n a.created_at AS \"created_at!: DateTime\",\n a.expires_at AS \"expires_at?: DateTime\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 7, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 8, "name": "original_name!", "type_info": "Text" }, { "ordinal": 9, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 10, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 11, "name": "hash!", "type_info": "Text" }, { "ordinal": 12, "name": "width?", "type_info": "Int4" }, { "ordinal": 13, "name": "height?", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid", "UuidArray" ] }, "nullable": [ false, false, true, true, false, true, false, true, false, true, false, false, true, true ] }, "hash": "bdbdee30d4f94a94f3d449aaea132512d8334a6a2636f898facd4e916a683f5e" } ================================================ FILE: crates/remote/.sqlx/query-bee3a7f9d08e5634eb32e750701822a8f4efa01d301c8227e67783b435ee90cc.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO pull_requests (id, url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\n SELECT gen_random_uuid(), url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id\n FROM UNNEST($1::text[], $2::int[], $3::pull_request_status[], $4::timestamptz[], $5::text[], $6::text[], $7::uuid[])\n AS t(url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id)\n RETURNING id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" } ], "parameters": { "Left": [ "TextArray", "Int4Array", { "Custom": { "name": "pull_request_status[]", "kind": { "Array": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } } } }, "TimestamptzArray", "TextArray", "TextArray", "UuidArray" ] }, "nullable": [ false ] }, "hash": "bee3a7f9d08e5634eb32e750701822a8f4efa01d301c8227e67783b435ee90cc" } ================================================ FILE: crates/remote/.sqlx/query-bf4a22fed2026657255fd032fcbc1ee14c27e48df5fa21fcc6202863520dbf98.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM pull_requests WHERE \"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "bf4a22fed2026657255fd032fcbc1ee14c27e48df5fa21fcc6202863520dbf98" } ================================================ FILE: crates/remote/.sqlx/query-bfc7aa46dc9d6d70c2fed471996ddcbf4d723f0e41aa17f7d2be0cf277350410.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_relationships WHERE \"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "bfc7aa46dc9d6d70c2fed471996ddcbf4d723f0e41aa17f7d2be0cf277350410" } ================================================ FILE: crates/remote/.sqlx/query-c27e4a5df0dbc872c6ae2c35abf0868b70ba141486e15a70e61c18e97f9e9213.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issues (id, project_id, status_id, title, description, priority, sort_order, extension_metadata, created_at)\n SELECT\n gen_random_uuid(),\n t.project_id,\n (SELECT id FROM project_statuses ps WHERE ps.project_id = t.project_id AND LOWER(ps.name) = LOWER(t.status_name)),\n t.title,\n t.description,\n NULL,\n 0.0,\n '{}'::jsonb,\n t.created_at\n FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::timestamptz[])\n AS t(project_id, status_name, title, description, created_at)\n RETURNING id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" } ], "parameters": { "Left": [ "UuidArray", "TextArray", "TextArray", "TextArray", "TimestamptzArray" ] }, "nullable": [ false ] }, "hash": "c27e4a5df0dbc872c6ae2c35abf0868b70ba141486e15a70e61c18e97f9e9213" } ================================================ FILE: crates/remote/.sqlx/query-c30d54026d89b9cf9c938f5aff5cf09ca12af2ab456094aac9a417473645b7e4.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT pubname FROM pg_publication WHERE pubname = ANY($1)", "describe": { "columns": [ { "ordinal": 0, "name": "pubname", "type_info": "Name" } ], "parameters": { "Left": [ "NameArray" ] }, "nullable": [ false ] }, "hash": "c30d54026d89b9cf9c938f5aff5cf09ca12af2ab456094aac9a417473645b7e4" } ================================================ FILE: crates/remote/.sqlx/query-c392eefb0fa2803536184053eaa22d63b6af8119f60419b8117f332cf48912de.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO oauth_accounts (\n user_id,\n provider,\n provider_user_id,\n email,\n username,\n display_name,\n avatar_url,\n encrypted_provider_tokens\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (provider, provider_user_id) DO UPDATE\n SET\n email = EXCLUDED.email,\n username = EXCLUDED.username,\n display_name = EXCLUDED.display_name,\n avatar_url = EXCLUDED.avatar_url,\n encrypted_provider_tokens = COALESCE(\n EXCLUDED.encrypted_provider_tokens,\n oauth_accounts.encrypted_provider_tokens\n )\n RETURNING\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "provider!", "type_info": "Text" }, { "ordinal": 3, "name": "provider_user_id!", "type_info": "Text" }, { "ordinal": 4, "name": "email?", "type_info": "Text" }, { "ordinal": 5, "name": "username?", "type_info": "Text" }, { "ordinal": 6, "name": "display_name?", "type_info": "Text" }, { "ordinal": 7, "name": "avatar_url?", "type_info": "Text" }, { "ordinal": 8, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 9, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Text", "Text", "Text", "Text" ] }, "nullable": [ false, false, false, false, true, true, true, true, true, false, false ] }, "hash": "c392eefb0fa2803536184053eaa22d63b6af8119f60419b8117f332cf48912de" } ================================================ FILE: crates/remote/.sqlx/query-c48b9162c22c3d0ad7d2e2f34ecca353b807876aebf3540e5a024669ac2bb613.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO notification_digest_deliveries (notification_id)\n SELECT notification_id\n FROM UNNEST($1::uuid[]) AS delivered(notification_id)\n ON CONFLICT (notification_id) DO NOTHING\n ", "describe": { "columns": [], "parameters": { "Left": [ "UuidArray" ] }, "nullable": [] }, "hash": "c48b9162c22c3d0ad7d2e2f34ecca353b807876aebf3540e5a024669ac2bb613" } ================================================ FILE: crates/remote/.sqlx/query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE token = $1 AND status = 'pending'\n FOR UPDATE\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "invited_by_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "email!", "type_info": "Text" }, { "ordinal": 4, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 5, "name": "status!: InvitationStatus", "type_info": { "Custom": { "name": "invitation_status", "kind": { "Enum": [ "pending", "accepted", "declined", "expired" ] } } } }, { "ordinal": 6, "name": "token!", "type_info": "Text" }, { "ordinal": 7, "name": "expires_at!", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, true, false, false, false, false, false, false, false ] }, "hash": "c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668" } ================================================ FILE: crates/remote/.sqlx/query-c6cccc00461c95d86edc5a1f66b8228fb438985f6b78f9d83663ecb11d59675f.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE github_app_installations\n SET suspended_at = NULL, updated_at = NOW()\n WHERE github_installation_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8" ] }, "nullable": [] }, "hash": "c6cccc00461c95d86edc5a1f66b8228fb438985f6b78f9d83663ecb11d59675f" } ================================================ FILE: crates/remote/.sqlx/query-c850c165c1041f6b9ef852f8bb6c36f0558bd305000151834a884d7629521d28.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n thumbnail_blob_path AS \"thumbnail_blob_path?\",\n original_name AS \"original_name!\",\n mime_type AS \"mime_type?\",\n size_bytes AS \"size_bytes!\",\n hash AS \"hash!\",\n width AS \"width?\",\n height AS \"height?\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM blobs\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 4, "name": "original_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 6, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 7, "name": "hash!", "type_info": "Text" }, { "ordinal": 8, "name": "width?", "type_info": "Int4" }, { "ordinal": 9, "name": "height?", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, false, true, false, false, true, true, false, false ] }, "hash": "c850c165c1041f6b9ef852f8bb6c36f0558bd305000151834a884d7629521d28" } ================================================ FILE: crates/remote/.sqlx/query-c91d02654edf56d33a7eb9d33d3423ac2b0a59bdd89eb7d8aeadbabb2af72314.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT p.organization_id\n FROM issues i\n JOIN projects p ON i.project_id = p.id\n WHERE i.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id", "type_info": "Uuid" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false ] }, "hash": "c91d02654edf56d33a7eb9d33d3423ac2b0a59bdd89eb7d8aeadbabb2af72314" } ================================================ FILE: crates/remote/.sqlx/query-c9499f4408f22989b6f55f1641e7e1a82b2f32e079c6b3ee0d9f6a47a15a2522.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n notify_on_issue_created AS \"notify_on_issue_created!\",\n notify_on_issue_assigned AS \"notify_on_issue_assigned!\"\n FROM project_notification_preferences\n WHERE project_id = $1 AND user_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "notify_on_issue_created!", "type_info": "Bool" }, { "ordinal": 3, "name": "notify_on_issue_assigned!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "c9499f4408f22989b6f55f1641e7e1a82b2f32e079c6b3ee0d9f6a47a15a2522" } ================================================ FILE: crates/remote/.sqlx/query-ca680e4e2a221ccaf578639b96730fa0d0fd4451d956f9dfa46670f5980c29a8.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE oauth_handoffs\n SET\n status = 'redeemed',\n encrypted_provider_tokens = NULL,\n redeemed_at = NOW()\n WHERE id = $1\n AND status = 'authorized'\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "ca680e4e2a221ccaf578639b96730fa0d0fd4451d956f9dfa46670f5980c29a8" } ================================================ FILE: crates/remote/.sqlx/query-cbeee2168e74df2896cbb063187cd1acc8a5429bfaec80f32764676dafd2cd1e.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT DISTINCT\n u.id AS \"id!: Uuid\",\n u.email AS \"email!\",\n u.first_name,\n u.last_name,\n u.username\n FROM notifications n\n JOIN users u ON u.id = n.user_id\n WHERE n.created_at >= $1\n AND n.created_at < $2\n AND n.dismissed_at IS NULL\n AND n.seen = FALSE\n AND NOT EXISTS (\n SELECT 1\n FROM notification_digest_deliveries d\n WHERE d.notification_id = n.id\n )\n ORDER BY u.id\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email!", "type_info": "Text" }, { "ordinal": 2, "name": "first_name", "type_info": "Text" }, { "ordinal": 3, "name": "last_name", "type_info": "Text" }, { "ordinal": 4, "name": "username", "type_info": "Text" } ], "parameters": { "Left": [ "Timestamptz", "Timestamptz" ] }, "nullable": [ false, false, true, true, true ] }, "hash": "cbeee2168e74df2896cbb063187cd1acc8a5429bfaec80f32764676dafd2cd1e" } ================================================ FILE: crates/remote/.sqlx/query-cc7d93b529cfbddc9921ae33533572062f2e072f5be0fcb26032cbfec2fb3118.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO blobs (\n id, project_id, blob_path, thumbnail_blob_path, original_name,\n mime_type, size_bytes, hash, width, height\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ON CONFLICT (blob_path) DO UPDATE SET\n updated_at = NOW()\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n thumbnail_blob_path AS \"thumbnail_blob_path?\",\n original_name AS \"original_name!\",\n mime_type AS \"mime_type?\",\n size_bytes AS \"size_bytes!\",\n hash AS \"hash!\",\n width AS \"width?\",\n height AS \"height?\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 4, "name": "original_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 6, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 7, "name": "hash!", "type_info": "Text" }, { "ordinal": 8, "name": "width?", "type_info": "Int4" }, { "ordinal": 9, "name": "height?", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Text", "Text", "Text", "Text", "Int8", "Text", "Int4", "Int4" ] }, "nullable": [ false, false, false, true, false, true, false, false, true, true, false, false ] }, "hash": "cc7d93b529cfbddc9921ae33533572062f2e072f5be0fcb26032cbfec2fb3118" } ================================================ FILE: crates/remote/.sqlx/query-cccab845031104e7a06d411cfbbbf7465f73051b30ec06d21a4c687ec175a58c.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE pull_requests SET\n status = CASE WHEN $1 THEN $2 ELSE status END,\n merged_at = CASE WHEN $3 THEN $4 ELSE merged_at END,\n merge_commit_sha = CASE WHEN $5 THEN $6 ELSE merge_commit_sha END,\n updated_at = NOW()\n WHERE id = $7\n RETURNING\n id AS \"id!: Uuid\",\n url AS \"url!: String\",\n number AS \"number!: i32\",\n status AS \"status!: PullRequestStatus\",\n merged_at AS \"merged_at: DateTime\",\n merge_commit_sha AS \"merge_commit_sha: String\",\n target_branch_name AS \"target_branch_name!: String\",\n issue_id AS \"issue_id!: Uuid\",\n workspace_id AS \"workspace_id: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "url!: String", "type_info": "Text" }, { "ordinal": 2, "name": "number!: i32", "type_info": "Int4" }, { "ordinal": 3, "name": "status!: PullRequestStatus", "type_info": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } }, { "ordinal": 4, "name": "merged_at: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "merge_commit_sha: String", "type_info": "Varchar" }, { "ordinal": 6, "name": "target_branch_name!: String", "type_info": "Text" }, { "ordinal": 7, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 8, "name": "workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 9, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Bool", { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } }, "Bool", "Timestamptz", "Bool", "Varchar", "Uuid" ] }, "nullable": [ false, false, false, false, true, true, false, false, true, false, false ] }, "hash": "cccab845031104e7a06d411cfbbbf7465f73051b30ec06d21a4c687ec175a58c" } ================================================ FILE: crates/remote/.sqlx/query-ce7908cdeecd4b4b94c92256bd800c165567ebe5644cfd702a9e4c0bb24091d4.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT n.nspname AS schema_name, c.relname AS table_name\n FROM pg_publication_rel pr\n JOIN pg_publication p ON pr.prpubid = p.oid\n JOIN pg_class c ON pr.prrelid = c.oid\n JOIN pg_namespace n ON c.relnamespace = n.oid\n WHERE p.pubname = 'electric_publication_default'", "describe": { "columns": [ { "ordinal": 0, "name": "schema_name", "type_info": "Name" }, { "ordinal": 1, "name": "table_name", "type_info": "Name" } ], "parameters": { "Left": [] }, "nullable": [ false, false ] }, "hash": "ce7908cdeecd4b4b94c92256bd800c165567ebe5644cfd702a9e4c0bb24091d4" } ================================================ FILE: crates/remote/.sqlx/query-d0e511b622ffba9354c3be61112b392f7c22eb9facc97730d5b4ee62c248fff8.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n encrypted_provider_tokens AS \"encrypted_provider_tokens?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_accounts\n WHERE user_id = $1\n AND provider = $2\n LIMIT 1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "provider!", "type_info": "Text" }, { "ordinal": 3, "name": "provider_user_id!", "type_info": "Text" }, { "ordinal": 4, "name": "email?", "type_info": "Text" }, { "ordinal": 5, "name": "username?", "type_info": "Text" }, { "ordinal": 6, "name": "display_name?", "type_info": "Text" }, { "ordinal": 7, "name": "avatar_url?", "type_info": "Text" }, { "ordinal": 8, "name": "encrypted_provider_tokens?", "type_info": "Text" }, { "ordinal": 9, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, false, true, true, true, true, true, false, false ] }, "hash": "d0e511b622ffba9354c3be61112b392f7c22eb9facc97730d5b4ee62c248fff8" } ================================================ FILE: crates/remote/.sqlx/query-d1a4753755833d5100bcf4b61449f58fa83f7ee511ce0b15c7dc00c2d8c01560.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_followers WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "d1a4753755833d5100bcf4b61449f58fa83f7ee511ce0b15c7dc00c2d8c01560" } ================================================ FILE: crates/remote/.sqlx/query-d2275416ba3ddb1bbaf929787b5df4c736084582ddebdbe9f4a4aa6853727484.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT COUNT(*)::BIGINT\n FROM issues i\n WHERE i.project_id = $1\n AND ($2::uuid IS NULL OR i.status_id = $2)\n AND ($3::uuid[] IS NULL OR i.status_id = ANY($3))\n AND ($4::issue_priority IS NULL OR i.priority = $4)\n AND ($5::uuid IS NULL OR i.parent_issue_id = $5)\n AND (\n $6::text IS NULL\n OR i.title ILIKE $6 ESCAPE '\\'\n OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\\'\n )\n AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\\')\n AND (\n $8::uuid IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_assignees ia\n WHERE ia.issue_id = i.id AND ia.user_id = $8\n )\n )\n AND (\n $9::uuid IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_tags it\n WHERE it.issue_id = i.id AND it.tag_id = $9\n )\n )\n AND (\n $10::uuid[] IS NULL\n OR EXISTS (\n SELECT 1\n FROM issue_tags it\n WHERE it.issue_id = i.id AND it.tag_id = ANY($10)\n )\n )\n ", "describe": { "columns": [ { "ordinal": 0, "name": "count", "type_info": "Int8" } ], "parameters": { "Left": [ "Uuid", "Uuid", "UuidArray", { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } }, "Uuid", "Text", "Text", "Uuid", "Uuid", "UuidArray" ] }, "nullable": [ null ] }, "hash": "d2275416ba3ddb1bbaf929787b5df4c736084582ddebdbe9f4a4aa6853727484" } ================================================ FILE: crates/remote/.sqlx/query-d4931e5f81b8a68f983d3e43b319a0f145339b7d8f878c3c1a765f41f3f4697c.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT EXISTS(SELECT 1 FROM workspaces WHERE local_workspace_id = $1) AS \"exists!\"", "describe": { "columns": [ { "ordinal": 0, "name": "exists!", "type_info": "Bool" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "d4931e5f81b8a68f983d3e43b319a0f145339b7d8f878c3c1a765f41f3f4697c" } ================================================ FILE: crates/remote/.sqlx/query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT role AS \"role!: MemberRole\"\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ", "describe": { "columns": [ { "ordinal": 0, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } } ], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [ false ] }, "hash": "d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06" } ================================================ FILE: crates/remote/.sqlx/query-da660b40d95d5fa5e9176b0b5859bb594e83fc21664f062f29ed148969b17c0b.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n gh_pr_url,\n claude_code_session_id,\n ip_address AS \"ip_address: IpNetwork\",\n review_cache,\n last_viewed_at,\n r2_path,\n deleted_at,\n created_at,\n email,\n pr_title,\n status,\n github_installation_id,\n pr_owner,\n pr_repo,\n pr_number\n FROM reviews\n WHERE id = $1 AND deleted_at IS NULL\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "gh_pr_url", "type_info": "Text" }, { "ordinal": 2, "name": "claude_code_session_id", "type_info": "Text" }, { "ordinal": 3, "name": "ip_address: IpNetwork", "type_info": "Inet" }, { "ordinal": 4, "name": "review_cache", "type_info": "Jsonb" }, { "ordinal": 5, "name": "last_viewed_at", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "r2_path", "type_info": "Text" }, { "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "email", "type_info": "Text" }, { "ordinal": 10, "name": "pr_title", "type_info": "Text" }, { "ordinal": 11, "name": "status", "type_info": "Text" }, { "ordinal": 12, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 13, "name": "pr_owner", "type_info": "Text" }, { "ordinal": 14, "name": "pr_repo", "type_info": "Text" }, { "ordinal": 15, "name": "pr_number", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, true, true, false, true, false, true, false, false, true, true, true, true ] }, "hash": "da660b40d95d5fa5e9176b0b5859bb594e83fc21664f062f29ed148969b17c0b" } ================================================ FILE: crates/remote/.sqlx/query-db645795e781123885506fe4f8e4f1a77a82d0dde22fd876f9b84dd04063db65.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM projects\n WHERE organization_id = $1\n ORDER BY sort_order ASC, created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Text" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "db645795e781123885506fe4f8e4f1a77a82d0dde22fd876f9b84dd04063db65" } ================================================ FILE: crates/remote/.sqlx/query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO users (id, email, first_name, last_name, username)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (id) DO UPDATE\n SET email = EXCLUDED.email,\n first_name = EXCLUDED.first_name,\n last_name = EXCLUDED.last_name,\n username = EXCLUDED.username\n RETURNING\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "email!", "type_info": "Text" }, { "ordinal": 2, "name": "first_name?", "type_info": "Text" }, { "ordinal": 3, "name": "last_name?", "type_info": "Text" }, { "ordinal": 4, "name": "username?", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text", "Text", "Text", "Text" ] }, "nullable": [ false, false, true, true, true, false, false ] }, "hash": "dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613" } ================================================ FILE: crates/remote/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM tags WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824" } ================================================ FILE: crates/remote/.sqlx/query-ddb471fb54ccc7b6438a15f8de8c9eba7e32eb51866b7f2871df2300bfe7cf40.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n hidden AS \"hidden!\",\n created_at AS \"created_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" }, { "ordinal": 4, "name": "sort_order!", "type_info": "Int4" }, { "ordinal": 5, "name": "hidden!", "type_info": "Bool" }, { "ordinal": 6, "name": "created_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Varchar", "Varchar", "Int4", "Bool", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "ddb471fb54ccc7b6438a15f8de8c9eba7e32eb51866b7f2871df2300bfe7cf40" } ================================================ FILE: crates/remote/.sqlx/query-df27dcabe19b0b1433865256b090f84474986985ec0d204ab17becd6d3568d0a.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_installations\n WHERE github_installation_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8" ] }, "nullable": [] }, "hash": "df27dcabe19b0b1433865256b090f84474986985ec0d204ab17becd6d3568d0a" } ================================================ FILE: crates/remote/.sqlx/query-dfad52e56108583960a73a9b89cb91e4da97e212313adc2db73a64cc8c473a87.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n a.id AS \"id!: Uuid\",\n a.blob_id AS \"blob_id!: Uuid\",\n a.issue_id AS \"issue_id?: Uuid\",\n a.comment_id AS \"comment_id?: Uuid\",\n a.created_at AS \"created_at!: DateTime\",\n a.expires_at AS \"expires_at?: DateTime\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\"\n FROM attachments a\n INNER JOIN blobs b ON b.id = a.blob_id\n WHERE a.comment_id = $1\n ORDER BY a.created_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 7, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 8, "name": "original_name!", "type_info": "Text" }, { "ordinal": 9, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 10, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 11, "name": "hash!", "type_info": "Text" }, { "ordinal": 12, "name": "width?", "type_info": "Int4" }, { "ordinal": 13, "name": "height?", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, true, false, true, false, true, false, false, true, true ] }, "hash": "dfad52e56108583960a73a9b89cb91e4da97e212313adc2db73a64cc8c473a87" } ================================================ FILE: crates/remote/.sqlx/query-dfbf03c5f333dfc7f531f415f4816603d080a544699705329dfe2e93e33c2886.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n b.id AS \"id!: Uuid\",\n b.project_id AS \"project_id!: Uuid\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\",\n b.created_at AS \"created_at!: DateTime\",\n b.updated_at AS \"updated_at!: DateTime\"\n FROM attachments a\n INNER JOIN blobs b ON b.id = a.blob_id\n WHERE a.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 4, "name": "original_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 6, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 7, "name": "hash!", "type_info": "Text" }, { "ordinal": 8, "name": "width?", "type_info": "Int4" }, { "ordinal": 9, "name": "height?", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, false, true, false, false, true, true, false, false ] }, "hash": "dfbf03c5f333dfc7f531f415f4816603d080a544699705329dfe2e93e33c2886" } ================================================ FILE: crates/remote/.sqlx/query-e0a011a3d29e5ae50ff06a264c39655e32be70ba76939a82184bf0dc5e8d6968.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n blob_path AS \"blob_path!\",\n thumbnail_blob_path AS \"thumbnail_blob_path?\",\n original_name AS \"original_name!\",\n mime_type AS \"mime_type?\",\n size_bytes AS \"size_bytes!\",\n hash AS \"hash!\",\n width AS \"width?\",\n height AS \"height?\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM blobs\n WHERE project_id = $1 AND hash = $2\n LIMIT 1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 3, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 4, "name": "original_name!", "type_info": "Text" }, { "ordinal": 5, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 6, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 7, "name": "hash!", "type_info": "Text" }, { "ordinal": 8, "name": "width?", "type_info": "Int4" }, { "ordinal": 9, "name": "height?", "type_info": "Int4" }, { "ordinal": 10, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Text" ] }, "nullable": [ false, false, false, true, false, true, false, false, true, true, false, false ] }, "hash": "e0a011a3d29e5ae50ff06a264c39655e32be70ba76939a82184bf0dc5e8d6968" } ================================================ FILE: crates/remote/.sqlx/query-e161b18662654bba364a273f67486b9366d5a972fc4968b03aa4c9067b92389d.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n a.id AS \"id!: Uuid\",\n a.blob_id AS \"blob_id!: Uuid\",\n a.issue_id AS \"issue_id?: Uuid\",\n a.comment_id AS \"comment_id?: Uuid\",\n a.created_at AS \"created_at!: DateTime\",\n a.expires_at AS \"expires_at?: DateTime\",\n b.blob_path AS \"blob_path!\",\n b.thumbnail_blob_path AS \"thumbnail_blob_path?\",\n b.original_name AS \"original_name!\",\n b.mime_type AS \"mime_type?\",\n b.size_bytes AS \"size_bytes!\",\n b.hash AS \"hash!\",\n b.width AS \"width?\",\n b.height AS \"height?\"\n FROM attachments a\n INNER JOIN blobs b ON b.id = a.blob_id\n WHERE a.id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "blob_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "comment_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "expires_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "blob_path!", "type_info": "Text" }, { "ordinal": 7, "name": "thumbnail_blob_path?", "type_info": "Text" }, { "ordinal": 8, "name": "original_name!", "type_info": "Text" }, { "ordinal": 9, "name": "mime_type?", "type_info": "Text" }, { "ordinal": 10, "name": "size_bytes!", "type_info": "Int8" }, { "ordinal": 11, "name": "hash!", "type_info": "Text" }, { "ordinal": 12, "name": "width?", "type_info": "Int4" }, { "ordinal": 13, "name": "height?", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, true, false, true, false, true, false, false, true, true ] }, "hash": "e161b18662654bba364a273f67486b9366d5a972fc4968b03aa4c9067b92389d" } ================================================ FILE: crates/remote/.sqlx/query-e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM notifications WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "e185203cf84e43b801dfb23b4159e34aeaef1154dcd3d6811ab504915497ccf7" } ================================================ FILE: crates/remote/.sqlx/query-e2894ddc831401000e89318423f70f221248b494ff81c1966e59c32e70a87502.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n user_id,\n notification_type as \"notification_type!: NotificationType\",\n payload as \"payload!: sqlx::types::Json\",\n issue_id,\n comment_id,\n seen,\n dismissed_at,\n created_at\n FROM notifications\n WHERE user_id = $1 AND dismissed_at IS NULL\n ORDER BY created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 4, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 5, "name": "issue_id", "type_info": "Uuid" }, { "ordinal": 6, "name": "comment_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "seen", "type_info": "Bool" }, { "ordinal": 8, "name": "dismissed_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, true, true, false, true, false ] }, "hash": "e2894ddc831401000e89318423f70f221248b494ff81c1966e59c32e70a87502" } ================================================ FILE: crates/remote/.sqlx/query-e2bf31db16ca8adc105f79f00c26d6af8b542f1f1e57e947ae39197d94dd3fed.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n assigned_at AS \"assigned_at!: DateTime\"\n FROM issue_assignees\n WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1)\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "assigned_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "e2bf31db16ca8adc105f79f00c26d6af8b542f1f1e57e947ae39197d94dd3fed" } ================================================ FILE: crates/remote/.sqlx/query-e509e51e9b1fe5e989713ab048e2641e6d1450f5506502b5a261e93dbb284226.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n n.id AS \"id!: Uuid\",\n n.notification_type AS \"notification_type!: NotificationType\",\n n.payload AS \"payload!: sqlx::types::Json\",\n n.issue_id AS \"issue_id?: Uuid\",\n n.created_at AS \"created_at!\",\n COALESCE(NULLIF(actor.first_name, ''), NULLIF(actor.username, ''), 'Someone') AS \"actor_name!\"\n FROM notifications n\n LEFT JOIN users actor\n ON actor.id = NULLIF(n.payload->>'actor_user_id', '')::uuid\n WHERE n.user_id = $1\n AND n.created_at >= $2\n AND n.created_at < $3\n AND n.dismissed_at IS NULL\n AND n.seen = FALSE\n AND NOT EXISTS (\n SELECT 1\n FROM notification_digest_deliveries d\n WHERE d.notification_id = n.id\n )\n ORDER BY n.created_at DESC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "notification_type!: NotificationType", "type_info": { "Custom": { "name": "notification_type", "kind": { "Enum": [ "issue_comment_added", "issue_status_changed", "issue_assignee_changed", "issue_deleted", "issue_title_changed", "issue_description_changed", "issue_priority_changed", "issue_unassigned", "issue_comment_reaction" ] } } } }, { "ordinal": 2, "name": "payload!: sqlx::types::Json", "type_info": "Jsonb" }, { "ordinal": 3, "name": "issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "actor_name!", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid", "Timestamptz", "Timestamptz" ] }, "nullable": [ false, false, false, true, false, null ] }, "hash": "e509e51e9b1fe5e989713ab048e2641e6d1450f5506502b5a261e93dbb284226" } ================================================ FILE: crates/remote/.sqlx/query-e553f31a70abb9d7e39755633f67f2b9c21ab6552986181acc10a1523852655c.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO github_app_pending_installations (organization_id, user_id, state_token, expires_at)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id,\n organization_id,\n user_id,\n state_token,\n expires_at,\n created_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "user_id", "type_info": "Uuid" }, { "ordinal": 3, "name": "state_token", "type_info": "Text" }, { "ordinal": 4, "name": "expires_at", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Text", "Timestamptz" ] }, "nullable": [ false, false, false, false, false, false ] }, "hash": "e553f31a70abb9d7e39755633f67f2b9c21ab6552986181acc10a1523852655c" } ================================================ FILE: crates/remote/.sqlx/query-ea41e984b0e7c1c952cb265659a443de1967c2d024be80ae1d9878e27b474986.json ================================================ { "db_name": "PostgreSQL", "query": "\n UPDATE github_app_installations\n SET repository_selection = $2, updated_at = NOW()\n WHERE github_installation_id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ "Int8", "Text" ] }, "nullable": [] }, "hash": "ea41e984b0e7c1c952cb265659a443de1967c2d024be80ae1d9878e27b474986" } ================================================ FILE: crates/remote/.sqlx/query-ec42318654455b31681de774e8d1e07efae222e1d5c97146a4e0054f74c0b2cc.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_followers WHERE \"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "ec42318654455b31681de774e8d1e07efae222e1d5c97146a4e0054f74c0b2cc" } ================================================ FILE: crates/remote/.sqlx/query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n omm.user_id AS \"user_id!: Uuid\",\n omm.role AS \"role!: MemberRole\",\n omm.joined_at AS \"joined_at!\",\n u.first_name AS \"first_name?\",\n u.last_name AS \"last_name?\",\n u.username AS \"username?\",\n u.email AS \"email?\",\n oa.avatar_url AS \"avatar_url?\"\n FROM organization_member_metadata omm\n INNER JOIN users u ON omm.user_id = u.id\n LEFT JOIN LATERAL (\n SELECT avatar_url\n FROM oauth_accounts\n WHERE user_id = omm.user_id\n ORDER BY created_at ASC\n LIMIT 1\n ) oa ON true\n WHERE omm.organization_id = $1\n ORDER BY omm.joined_at ASC\n ", "describe": { "columns": [ { "ordinal": 0, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 2, "name": "joined_at!", "type_info": "Timestamptz" }, { "ordinal": 3, "name": "first_name?", "type_info": "Text" }, { "ordinal": 4, "name": "last_name?", "type_info": "Text" }, { "ordinal": 5, "name": "username?", "type_info": "Text" }, { "ordinal": 6, "name": "email?", "type_info": "Text" }, { "ordinal": 7, "name": "avatar_url?", "type_info": "Text" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, true, true, true, false, true ] }, "hash": "ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401" } ================================================ FILE: crates/remote/.sqlx/query-f00d3b1e7ce2a7fe5e8e3132e32b7ea50b0d6865f0708b6113bea68a54d857f4.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM github_app_repositories\n WHERE installation_id = $1 AND github_repo_id = ANY($2)\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Int8Array" ] }, "nullable": [] }, "hash": "f00d3b1e7ce2a7fe5e8e3132e32b7ea50b0d6865f0708b6113bea68a54d857f4" } ================================================ FILE: crates/remote/.sqlx/query-f036504d4bec68969c545881e684ab0dd9fcb85285e4d541d97a7e6be1681e38.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n url AS \"url!: String\",\n number AS \"number!: i32\",\n status AS \"status!: PullRequestStatus\",\n merged_at AS \"merged_at: DateTime\",\n merge_commit_sha AS \"merge_commit_sha: String\",\n target_branch_name AS \"target_branch_name!: String\",\n issue_id AS \"issue_id!: Uuid\",\n workspace_id AS \"workspace_id: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM pull_requests\n WHERE url = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "url!: String", "type_info": "Text" }, { "ordinal": 2, "name": "number!: i32", "type_info": "Int4" }, { "ordinal": 3, "name": "status!: PullRequestStatus", "type_info": { "Custom": { "name": "pull_request_status", "kind": { "Enum": [ "open", "merged", "closed" ] } } } }, { "ordinal": 4, "name": "merged_at: DateTime", "type_info": "Timestamptz" }, { "ordinal": 5, "name": "merge_commit_sha: String", "type_info": "Varchar" }, { "ordinal": 6, "name": "target_branch_name!: String", "type_info": "Text" }, { "ordinal": 7, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 8, "name": "workspace_id: Uuid", "type_info": "Uuid" }, { "ordinal": 9, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Text" ] }, "nullable": [ false, false, false, false, true, true, false, false, true, false, false ] }, "hash": "f036504d4bec68969c545881e684ab0dd9fcb85285e4d541d97a7e6be1681e38" } ================================================ FILE: crates/remote/.sqlx/query-f04fc738e518f28f1f148245ae92c177289f673ef6a631d65b92bd5ee841bb52.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n author_id AS \"author_id: Uuid\",\n parent_id AS \"parent_id: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM issue_comments\n WHERE issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "author_id: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "parent_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "message!", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, false, false ] }, "hash": "f04fc738e518f28f1f148245ae92c177289f673ef6a631d65b92bd5ee841bb52" } ================================================ FILE: crates/remote/.sqlx/query-f20260cbe7dff433f5aefa4fe14fa9bc89a6ad97d550420c768e326de6ae5ae6.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n issue_prefix AS \"issue_prefix!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organizations\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "name!", "type_info": "Text" }, { "ordinal": 2, "name": "slug!", "type_info": "Text" }, { "ordinal": 3, "name": "is_personal!", "type_info": "Bool" }, { "ordinal": 4, "name": "issue_prefix!", "type_info": "Varchar" }, { "ordinal": 5, "name": "created_at!", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, false ] }, "hash": "f20260cbe7dff433f5aefa4fe14fa9bc89a6ad97d550420c768e326de6ae5ae6" } ================================================ FILE: crates/remote/.sqlx/query-f2e8e193cc183a69527708cdd65fc8f0dc9ac4d9fcf67b8ac5285d068c161e06.json ================================================ { "db_name": "PostgreSQL", "query": "DELETE FROM issue_comments WHERE id = $1", "describe": { "columns": [], "parameters": { "Left": [ "Uuid" ] }, "nullable": [] }, "hash": "f2e8e193cc183a69527708cdd65fc8f0dc9ac4d9fcf67b8ac5285d068c161e06" } ================================================ FILE: crates/remote/.sqlx/query-f360cdb953a3e2fb64123ab8351d42029b58919a0ac0e8900320fee60c5c93b2.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id,\n organization_id,\n github_installation_id,\n github_account_login,\n github_account_type,\n repository_selection,\n installed_by_user_id,\n suspended_at,\n created_at,\n updated_at\n FROM github_app_installations\n WHERE organization_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 3, "name": "github_account_login", "type_info": "Text" }, { "ordinal": 4, "name": "github_account_type", "type_info": "Text" }, { "ordinal": 5, "name": "repository_selection", "type_info": "Text" }, { "ordinal": 6, "name": "installed_by_user_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "suspended_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true, false, false ] }, "hash": "f360cdb953a3e2fb64123ab8351d42029b58919a0ac0e8900320fee60c5c93b2" } ================================================ FILE: crates/remote/.sqlx/query-f4015e2352122c1819ff7e7a4dff62b9387f439d80f47bf457b20663c24b861a.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM users WHERE \"id\" IN (SELECT user_id FROM organization_member_metadata WHERE \"organization_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "f4015e2352122c1819ff7e7a4dff62b9387f439d80f47bf457b20663c24b861a" } ================================================ FILE: crates/remote/.sqlx/query-f403f8876022d19b330e4fc0b550e2ef8bb14a08de3530cb541ae09e1a479d45.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM issue_tags WHERE \"issue_id\" IN (SELECT id FROM issues WHERE \"project_id\" = $1)", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "f403f8876022d19b330e4fc0b550e2ef8bb14a08de3530cb541ae09e1a479d45" } ================================================ FILE: crates/remote/.sqlx/query-f5eff8b44dfd3aceb4e1fc1a4b58c4e74c8fac220e9943daf4103eb9e57af051.json ================================================ { "db_name": "PostgreSQL", "query": "SELECT 1 AS v FROM workspaces WHERE \"project_id\" = $1", "describe": { "columns": [ { "ordinal": 0, "name": "v", "type_info": "Int4" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ null ] }, "hash": "f5eff8b44dfd3aceb4e1fc1a4b58c4e74c8fac220e9943daf4103eb9e57af051" } ================================================ FILE: crates/remote/.sqlx/query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json ================================================ { "db_name": "PostgreSQL", "query": "\n DELETE FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", "Uuid" ] }, "nullable": [] }, "hash": "f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2" } ================================================ FILE: crates/remote/.sqlx/query-f9491f7f61aec53b057689bc722b6f20c2646510bfcd8b38c27576769a53e750.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n organization_id AS \"organization_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n role AS \"role!: MemberRole\",\n joined_at AS \"joined_at!\",\n last_seen_at\n FROM organization_member_metadata\n WHERE organization_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "organization_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "user_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "role!: MemberRole", "type_info": { "Custom": { "name": "member_role", "kind": { "Enum": [ "admin", "member" ] } } } }, { "ordinal": 3, "name": "joined_at!", "type_info": "Timestamptz" }, { "ordinal": 4, "name": "last_seen_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false, true ] }, "hash": "f9491f7f61aec53b057689bc722b6f20c2646510bfcd8b38c27576769a53e750" } ================================================ FILE: crates/remote/.sqlx/query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n FROM tags\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "name!", "type_info": "Varchar" }, { "ordinal": 3, "name": "color!", "type_info": "Varchar" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, false, false ] }, "hash": "fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159" } ================================================ FILE: crates/remote/.sqlx/query-fcffbcc41e058a6d055bec006e7287fcfb26b609107d753e372faeb7f9d92302.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO github_app_installations (\n organization_id,\n github_installation_id,\n github_account_login,\n github_account_type,\n repository_selection,\n installed_by_user_id\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (github_installation_id) DO UPDATE SET\n organization_id = EXCLUDED.organization_id,\n github_account_login = EXCLUDED.github_account_login,\n github_account_type = EXCLUDED.github_account_type,\n repository_selection = EXCLUDED.repository_selection,\n installed_by_user_id = EXCLUDED.installed_by_user_id,\n suspended_at = NULL,\n updated_at = NOW()\n RETURNING\n id,\n organization_id,\n github_installation_id,\n github_account_login,\n github_account_type,\n repository_selection,\n installed_by_user_id,\n suspended_at,\n created_at,\n updated_at\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id", "type_info": "Uuid" }, { "ordinal": 1, "name": "organization_id", "type_info": "Uuid" }, { "ordinal": 2, "name": "github_installation_id", "type_info": "Int8" }, { "ordinal": 3, "name": "github_account_login", "type_info": "Text" }, { "ordinal": 4, "name": "github_account_type", "type_info": "Text" }, { "ordinal": 5, "name": "repository_selection", "type_info": "Text" }, { "ordinal": 6, "name": "installed_by_user_id", "type_info": "Uuid" }, { "ordinal": 7, "name": "suspended_at", "type_info": "Timestamptz" }, { "ordinal": 8, "name": "created_at", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "updated_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Int8", "Text", "Text", "Text", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true, false, false ] }, "hash": "fcffbcc41e058a6d055bec006e7287fcfb26b609107d753e372faeb7f9d92302" } ================================================ FILE: crates/remote/.sqlx/query-fe9ae1da931e14f97d432ad34fe636b4854c7f85665b90337b342663bdde68b9.json ================================================ { "db_name": "PostgreSQL", "query": "\n SELECT\n id AS \"id!: Uuid\",\n issue_id AS \"issue_id!: Uuid\",\n author_id AS \"author_id: Uuid\",\n parent_id AS \"parent_id: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM issue_comments\n WHERE id = $1\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "issue_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "author_id: Uuid", "type_info": "Uuid" }, { "ordinal": 3, "name": "parent_id: Uuid", "type_info": "Uuid" }, { "ordinal": 4, "name": "message!", "type_info": "Text" }, { "ordinal": 5, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 6, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid" ] }, "nullable": [ false, false, true, true, false, false, false ] }, "hash": "fe9ae1da931e14f97d432ad34fe636b4854c7f85665b90337b342663bdde68b9" } ================================================ FILE: crates/remote/.sqlx/query-ffea7acda162fba35e1b1acd2c6791bc917f086bf1c34816178282f0579c1eeb.json ================================================ { "db_name": "PostgreSQL", "query": "\n INSERT INTO issues (\n id, project_id, status_id, title, description, priority,\n start_date, target_date, completed_at, sort_order,\n parent_issue_id, parent_issue_sort_order, extension_metadata,\n creator_user_id\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n issue_number AS \"issue_number!\",\n simple_id AS \"simple_id!\",\n status_id AS \"status_id!: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n priority AS \"priority: IssuePriority\",\n start_date AS \"start_date?: DateTime\",\n target_date AS \"target_date?: DateTime\",\n completed_at AS \"completed_at?: DateTime\",\n sort_order AS \"sort_order!\",\n parent_issue_id AS \"parent_issue_id?: Uuid\",\n parent_issue_sort_order AS \"parent_issue_sort_order?\",\n extension_metadata AS \"extension_metadata!: Value\",\n creator_user_id AS \"creator_user_id?: Uuid\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", "describe": { "columns": [ { "ordinal": 0, "name": "id!: Uuid", "type_info": "Uuid" }, { "ordinal": 1, "name": "project_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 2, "name": "issue_number!", "type_info": "Int4" }, { "ordinal": 3, "name": "simple_id!", "type_info": "Varchar" }, { "ordinal": 4, "name": "status_id!: Uuid", "type_info": "Uuid" }, { "ordinal": 5, "name": "title!", "type_info": "Varchar" }, { "ordinal": 6, "name": "description?", "type_info": "Text" }, { "ordinal": 7, "name": "priority: IssuePriority", "type_info": { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } } }, { "ordinal": 8, "name": "start_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 9, "name": "target_date?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 10, "name": "completed_at?: DateTime", "type_info": "Timestamptz" }, { "ordinal": 11, "name": "sort_order!", "type_info": "Float8" }, { "ordinal": 12, "name": "parent_issue_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 13, "name": "parent_issue_sort_order?", "type_info": "Float8" }, { "ordinal": 14, "name": "extension_metadata!: Value", "type_info": "Jsonb" }, { "ordinal": 15, "name": "creator_user_id?: Uuid", "type_info": "Uuid" }, { "ordinal": 16, "name": "created_at!: DateTime", "type_info": "Timestamptz" }, { "ordinal": 17, "name": "updated_at!: DateTime", "type_info": "Timestamptz" } ], "parameters": { "Left": [ "Uuid", "Uuid", "Uuid", "Varchar", "Text", { "Custom": { "name": "issue_priority", "kind": { "Enum": [ "urgent", "high", "medium", "low" ] } } }, "Timestamptz", "Timestamptz", "Timestamptz", "Float8", "Uuid", "Float8", "Jsonb", "Uuid" ] }, "nullable": [ false, false, false, false, false, false, true, true, true, true, true, false, true, true, false, true, false, false ] }, "hash": "ffea7acda162fba35e1b1acd2c6791bc917f086bf1c34816178282f0579c1eeb" } ================================================ FILE: crates/remote/AGENTS.md ================================================ # Remote Crate — Agent Guidelines The `remote` crate is the hosted Vibe Kanban Cloud server: an Axum HTTP API, a React SPA frontend, and real-time sync via ElectricSQL. > See also: [root AGENTS.md](../../AGENTS.md) for repo-wide conventions. ## Architecture ``` remote-server (Axum, port 8081) ├── /v1/* REST API (CRUD + auth + webhooks) ├── /shape/* ElectricSQL proxy (auth-gated shape subscriptions) └── /srv/static React SPA (built by Vite, served as fallback) PostgreSQL (port 5432) └── wal_level=logical, electric_sync role with REPLICATION ElectricSQL (port 3000, internal) └── Subscribes to Postgres via logical replication, streams shapes over HTTP ``` ## Build & Run ```bash # (from the repo root) pnpm run remote:dev # Run desktop client against local server export VK_SHARED_API_BASE=http://localhost:3000 pnpm run dev ``` To teardown and clean the remote stack (including wiping the database): ``` (from the repo root) pnpm run remote:dev:clean ``` Multi-stage Docker build: Node (frontend) → Rust (server) → Debian slim runtime. The billing crate (`vk-billing` feature) is a private dependency stripped at build time when `FEATURES` is empty. Do not add imports from the `billing` crate without gating them behind `#[cfg(feature = "vk-billing")]`. ## Key Modules | Module | Purpose | |--------|---------| | `app.rs` | Server bootstrap: pool → migrations → electric role → JWT → OAuth → services → listen | | `config.rs` | `RemoteServerConfig` parsed from env vars. Empty strings treated as unset. | | `state.rs` | `AppState` shared across all routes (pool, JWT, OAuth, billing, R2, etc.) | | `shapes.rs` | 16 const `ShapeDefinition` instances for ElectricSQL sync | | `shape_definition.rs` | `ShapeDefinition` struct, `ShapeExport` trait, `define_shape!` macro | | `mutation_definition.rs` | `MutationBuilder` for type-safe CRUD routes + TS type generation | | `response.rs` | `MutationResponse` — wraps data + Postgres `txid` | | `routes/electric_proxy.rs` | Auth-gated proxy forwarding shape requests to ElectricSQL | | `routes/mod.rs` | Router tree, SPA fallback from `/srv/static` | | `db/mod.rs` | Pool creation, migrations, `ensure_electric_role_password()` | | `auth/` | JWT, OAuth providers (GitHub/Google), session middleware | ## ElectricSQL Integration Vibe Kanban uses [ElectricSQL](https://electric-sql.com) as a read-path sync engine: Postgres → ElectricSQL → clients over HTTP shapes. Writes go through the REST API. ### How It Works 1. **Shapes** are single-table subscriptions with optional `WHERE`/`columns` filters, defined as constants in `shapes.rs`. 2. The **electric proxy** (`routes/electric_proxy.rs`) checks org/project membership, then forwards shape requests to the internal ElectricSQL service. 3. **Mutations** (create/update/delete) go through REST endpoints and return `MutationResponse` containing the Postgres transaction ID (`txid`). 4. The frontend uses `txid` to know when Electric has caught up — once the mutation appears in the Electric stream, optimistic state is dropped. ### The txid Handshake Every mutation handler must return the Postgres transaction ID: ```rust // In a route handler let result = db::issues::create_issue(&pool, &payload).await?; // MutationResponse includes txid from pg_current_xact_id() Ok(Json(MutationResponse { data: result.data, txid: result.txid })) ``` The frontend awaits this txid on the Electric stream before dropping optimistic state. Omitting the txid causes UI flicker. ### Adding a New Synced Table 1. **Create a migration** that creates the table and calls `ALTER TABLE ... REPLICA IDENTITY FULL` + `CALL electric.electrify('table_name')` (see `20260114000000_electric_sync_tables.sql` for examples). 2. **Define a shape** in `shapes.rs` using the `define_shape!` macro. Shapes are parameterised by scope (organization, project, issue). 3. **Add a proxy route** if the shape needs a new scope pattern in `electric_proxy.rs`. 4. **Return txid** from all mutation routes for that table. ### Security - **ElectricSQL is internal only** — never expose it directly to clients. All shape requests go through the auth-gated proxy in `electric_proxy.rs`. - Shape definitions (table, WHERE, columns) are server-controlled constants. The client cannot request arbitrary tables. ## Mutation Pattern All CRUD routes follow a consistent pattern using `MutationBuilder`: ```rust MutationBuilder::::new("entities") .list(list_handler) .get(get_handler) .create(create_handler) .update(update_handler) .delete(delete_handler) .build() ``` This generates both the Axum router and TypeScript type metadata (via `HasJsonPayload` trait). When adding a new entity, follow this pattern so types are auto-generated by `pnpm run generate-types`. ## Authentication & Authorisation - **JWT** (`auth/jwt.rs`): Signed with `VIBEKANBAN_REMOTE_JWT_SECRET`. All protected routes use `require_session` middleware. - **OAuth** (`auth/provider.rs`): GitHub and Google. At least one must be configured. Empty env vars are treated as disabled. - **Membership**: All resource routes check organisation/project membership before DB access. Use `RequestContext` from the middleware to get user info. ## Frontend (`packages/remote-web/`) - React 18 + React Router 7 + Vite + Tailwind - Built during Docker image creation, served from `/srv/static` - Uses `VITE_APP_BASE_URL` and `VITE_API_BASE_URL` (baked in at build time) - OAuth uses PKCE flow (`pkce.ts`) - ElectricSQL shapes consumed via the proxy at `/shape/*` ## Database - **Migrations**: SQLx-managed in `migrations/`, run at startup. Add new migrations with timestamp prefix. - **Offline mode**: Use `pnpm run remote:prepare-db` to generate SQLx offline data for CI builds. - **Pool**: 10 max connections. ## Testing ```bash cargo test --manifest-path crates/remote/Cargo.toml ``` SQLx compile-time checks require either a running Postgres or offline query data (`.sqlx/` directory). Run `pnpm run remote:prepare-db` to do this. ## Shared Types (`api-types` crate) Types shared between the remote server and the local desktop application belong in the `api-types` crate (`crates/api-types/`), not in the remote crate itself. Both `remote` and `server` depend on `api-types`. The crate contains: - **Row types** — API representations of database entities (`Issue`, `Project`, `User`, `Workspace`, etc.) - **Request types** — create/update payloads (`CreateIssueRequest`, `UpdateProjectRequest`, etc.) - **Shared enums** — `IssuePriority`, `MemberRole`, `PullRequestStatus`, `NotificationType`, etc. All types derive `TS` from `ts-rs` so they can be exported to TypeScript automatically. When adding a new entity that will be used by both backends, define its types in `api-types`. ## Type Generation (`generate_types.rs`) The binary at `src/bin/generate_types.rs` generates `shared/remote-types.ts` — the single TypeScript file consumed by the remote frontend. Run it with: ```bash pnpm run remote:generate-types # write shared/remote-types.ts pnpm run remote:generate-types --check # CI mode — exits non-zero if file is stale ``` The generated file contains: 1. **TypeScript interfaces** for every row and request type from `api-types` (each type's `::decl()` call in the `type_decls` vec). 2. **`ShapeDefinition` constants** — one per ElectricSQL shape, sourced from `shapes::all_shapes()`. 3. **`MutationDefinition` constants** — one per CRUD entity, sourced from `routes::all_mutation_definitions()`. 4. **Type helpers** (`MutationRowType`, `MutationCreateType`, `MutationUpdateType`) for extracting types from a mutation definition. When adding a new type to `api-types` that the remote frontend needs, add its `::decl()` call to the `type_decls` vec in `generate_types.rs` and re-run the generator. > The local desktop app has a separate generator (`crates/server/src/bin/generate_types.rs`) that outputs `shared/types.ts`. ## Common Pitfalls - **Empty string vs unset**: Docker Compose `${VAR:-}` produces `""`, which `std::env::var()` returns as `Ok("")`. Always check `!v.is_empty()` for optional config. - **ElectricSQL startup order**: Remote server must start first to create the `electric_sync` role. ElectricSQL will fail to connect if it starts before the server runs migrations. - **Billing feature gate**: All billing code must be behind `#[cfg(feature = "vk-billing")]`. The `billing` crate is stripped from Cargo.toml during self-hosted Docker builds. - **Frontend URL vars are build-time**: `VITE_*` variables are baked into the JS bundle. Changing them requires a rebuild. - **SPA fallback path**: The frontend is served from `/srv/static` (hardcoded). This path only exists inside the Docker container. ================================================ FILE: crates/remote/Cargo.toml ================================================ [package] name = "remote" version = "0.1.24" edition = "2024" publish = false [[bin]] name = "remote-generate-types" path = "src/bin/generate_types.rs" [features] default = [] vk-billing = ["dep:billing"] [dependencies] # private crate for billing functionality billing = { git = "ssh://git@github.com/BloopAI/vibe-kanban-private", branch = "main", package = "billing", optional = true } anyhow = "1.0" axum = { version = "0.8.4", features = ["macros", "multipart", "ws"] } axum-extra = { version = "0.10.3", features = ["typed-header"] } aes-gcm = "0.10" chrono = { version = "0.4", features = ["serde"] } futures = "0.3" futures-util = "0.3" async-trait = "0.1" reqwest = { version = "0.13", default-features = false, features = ["json", "query", "form", "stream", "rustls"] } otel-reqwest = { package = "reqwest", version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots-no-provider"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } secrecy = "0.10.3" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono", "json", "macros", "migrate", "ipnetwork"] } ipnetwork = "0.20" tokio = { version = "1.0", features = ["full"] } tower-http = { version = "0.5", features = ["cors", "request-id", "trace", "fs", "validate-request", "compression-gzip", "compression-br"] } tracing = "0.1.43" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } tracing-error = "0.2" tracing-opentelemetry = "0.32" opentelemetry = { version = "0.31", features = ["trace"] } opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] } opentelemetry-application-insights = "0.44" opentelemetry-http = { version = "0.31", features = ["reqwest", "reqwest-blocking"] } thiserror = "2.0.12" ts-rs = { git = "https://github.com/xazukx/ts-rs.git", branch = "use-ts-enum", features = ["uuid-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] } api-types = { path = "../api-types" } utils = { path = "../utils" } uuid = { version = "1", features = ["serde", "v4"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } rand = "0.9" sha2 = "0.10" hmac = "0.12" subtle = "2.5" hex = "0.4" urlencoding = "2.1" url = "2.5" base64 = "0.22" aws-sdk-s3 = { version = "1.65", default-features = false, features = ["behavior-version-latest", "rt-tokio"] } aws-credential-types = "1.2" azure_core = "0.31" azure_storage_blob = "0.8" azure_identity = "0.31" time = "0.3" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } tempfile = "3" tar = "0.4" flate2 = "1.0" [workspace] ================================================ FILE: crates/remote/Dockerfile ================================================ # syntax=docker/dockerfile:1.6 ARG APP_NAME=remote ARG FEATURES="" ARG VITE_RELAY_API_BASE_URL="" ARG VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY="" FROM node:20-alpine AS fe-builder ARG VITE_RELAY_API_BASE_URL ARG VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY WORKDIR /repo ENV VITE_RELAY_API_BASE_URL=${VITE_RELAY_API_BASE_URL} ENV VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY=${VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY} RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages/local-web/package.json packages/local-web/package.json COPY packages/remote-web/package.json packages/remote-web/package.json COPY packages/ui/package.json packages/ui/package.json COPY packages/web-core/package.json packages/web-core/package.json RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile COPY packages/remote-web/ packages/remote-web/ COPY packages/local-web/tailwind.new.config.js packages/local-web/tailwind.new.config.js COPY packages/public/ packages/public/ COPY packages/ui/ packages/ui/ COPY packages/web-core/ packages/web-core/ COPY shared/ shared/ RUN pnpm -C packages/remote-web build FROM rust:1.93-slim-bookworm AS builder ARG APP_NAME ARG FEATURES ARG POSTHOG_API_KEY="" ARG POSTHOG_API_ENDPOINT="" ARG SENTRY_DSN_REMOTE="" ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse ENV CARGO_NET_GIT_FETCH_WITH_CLI=true ENV CARGO_TARGET_DIR=/app/target ENV POSTHOG_API_KEY=${POSTHOG_API_KEY} ENV POSTHOG_API_ENDPOINT=${POSTHOG_API_ENDPOINT} ENV SENTRY_DSN_REMOTE=${SENTRY_DSN_REMOTE} # Install build dependencies, plus git and openssh-client for private repo access RUN apt-get update \ && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates git openssh-client \ && mkdir -p -m 0700 /root/.ssh \ && ssh-keyscan github.com >> /root/.ssh/known_hosts \ && chmod 600 /root/.ssh/known_hosts \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY rust-toolchain.toml ./ RUN cargo --version >/dev/null COPY Cargo.toml Cargo.lock ./ # Copy workspace member manifests so Cargo can resolve workspace-shared deps # without invalidating the build on every unrelated source change. COPY crates/server/Cargo.toml crates/server/Cargo.toml COPY crates/trusted-key-auth/Cargo.toml crates/trusted-key-auth/Cargo.toml COPY crates/mcp/Cargo.toml crates/mcp/Cargo.toml COPY crates/db/Cargo.toml crates/db/Cargo.toml COPY crates/executors/Cargo.toml crates/executors/Cargo.toml COPY crates/services/Cargo.toml crates/services/Cargo.toml COPY crates/worktree-manager/Cargo.toml crates/worktree-manager/Cargo.toml COPY crates/workspace-manager/Cargo.toml crates/workspace-manager/Cargo.toml COPY crates/relay-control/Cargo.toml crates/relay-control/Cargo.toml COPY crates/server-info/Cargo.toml crates/server-info/Cargo.toml COPY crates/git/Cargo.toml crates/git/Cargo.toml COPY crates/git-host/Cargo.toml crates/git-host/Cargo.toml COPY crates/local-deployment/Cargo.toml crates/local-deployment/Cargo.toml COPY crates/deployment/Cargo.toml crates/deployment/Cargo.toml COPY crates/review/Cargo.toml crates/review/Cargo.toml COPY crates/api-types crates/api-types COPY crates/remote crates/remote COPY crates/utils crates/utils COPY assets assets RUN mkdir -p /app/bin # When building without FEATURES (self-hosted), strip the private billing dependency # and its feature flag from Cargo.toml so cargo doesn't try to fetch the private git repo. # Also remove the Cargo.lock which references the private repo. RUN if [ -z "${FEATURES}" ]; then \ sed -i '/^billing = {.*vibe-kanban-private.*/d' crates/remote/Cargo.toml; \ sed -i '/^# private crate for billing/d' crates/remote/Cargo.toml; \ sed -i 's/^vk-billing = \["dep:billing"\]/vk-billing = []/' crates/remote/Cargo.toml; \ rm -f crates/remote/Cargo.lock; \ fi RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \ --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \ --mount=type=cache,id=remote-target,target=/app/target \ --mount=type=ssh \ cargo build ${FEATURES:+--locked} --release --manifest-path crates/remote/Cargo.toml ${FEATURES:+--features ${FEATURES}} \ && cp /app/target/release/${APP_NAME} /app/bin/${APP_NAME} FROM debian:bookworm-slim AS runtime ARG APP_NAME RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates libssl3 wget git \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --create-home --uid 10001 appuser WORKDIR /srv COPY --from=builder /app/bin/${APP_NAME} /usr/local/bin/${APP_NAME} COPY --from=fe-builder /repo/packages/remote-web/dist /srv/static USER appuser ENV SERVER_LISTEN_ADDR=0.0.0.0:8081 \ RUST_LOG=info EXPOSE 8081 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["wget","--spider","-q","http://127.0.0.1:8081/v1/health"] ENTRYPOINT ["/usr/local/bin/remote"] ================================================ FILE: crates/remote/README.md ================================================ # Remote service The `remote` crate contains the implementation of the Vibe Kanban hosted API. ## Prerequisites Create a `.env.remote` file in `crates/remote/` (this matches `pnpm run remote:dev`): ```env # Required — generate with: openssl rand -base64 48 VIBEKANBAN_REMOTE_JWT_SECRET=your_base64_encoded_secret # Required — password for the electric_sync database role used by ElectricSQL ELECTRIC_ROLE_PASSWORD=your_secure_password # OAuth — at least one provider (GitHub or Google) must be configured GITHUB_OAUTH_CLIENT_ID=your_github_web_app_client_id GITHUB_OAUTH_CLIENT_SECRET=your_github_web_app_client_secret GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= # Relay (required for tunnel/relay features) # For local HTTPS via Caddy on :3001: VITE_RELAY_API_BASE_URL=https://relay.localhost:3001 # Optional — enables Virtuoso Message List license for remote web UI VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY= # Optional — leave empty to disable invitation emails LOOPS_EMAIL_API_KEY= ``` Generate `VIBEKANBAN_REMOTE_JWT_SECRET` once using `openssl rand -base64 48` and copy the value into `.env.remote`. ## Run the stack locally From the repo root: ```bash pnpm run remote:dev ``` Equivalent manual command: ```bash cd crates/remote docker compose --env-file .env.remote -f docker-compose.yml up --build ``` This starts PostgreSQL, ElectricSQL, the Remote Server, and the Relay Server. - Remote web UI/API: `https://localhost:3001` (via Caddy) or `http://localhost:3000` (direct) - Relay API: `http://localhost:8082` - Postgres: `postgres://remote:remote@localhost:5433/remote` ## Run Vibe Kanban To connect the desktop client to your local remote server (without relay/tunnel): ```bash export VK_SHARED_API_BASE=https://localhost:3001 pnpm run dev ``` ## Local HTTPS with Caddy The stack defaults to `https://localhost:3001` as its public URL. Use [Caddy](https://caddyserver.com) as a reverse proxy to terminate TLS — it automatically provisions a locally-trusted certificate for `localhost`. ### 1. Install Caddy ```bash # macOS brew install caddy # Debian/Ubuntu sudo apt install caddy ``` ### 2. Create a Caddyfile Create a `Caddyfile` in the repository root: ```text localhost:3001, relay.localhost:3001, *.relay.localhost:3001 { tls internal @relay host relay.localhost *.relay.localhost handle @relay { reverse_proxy 127.0.0.1:8082 } @app expression `{http.request.host} == "localhost:3001" || {http.request.host} == "localhost"` handle @app { reverse_proxy 127.0.0.1:3000 } respond "not found" 404 } ``` ### 3. Update OAuth callback URLs Update your OAuth application to use `https://localhost:3001`: - **GitHub**: `https://localhost:3001/v1/oauth/github/callback` - **Google**: `https://localhost:3001/v1/oauth/google/callback` ### 4. Start everything Start Docker services as usual, then start Caddy in a separate terminal: ```bash # Terminal 1 — start the stack pnpm run remote:dev # Terminal 2 — start Caddy (from repo root) caddy run --config Caddyfile ``` The first time Caddy runs it installs a local CA certificate — you may be prompted for your password. Open **https://localhost:3001** in your browser. > **Tip:** To use plain HTTP instead (no Caddy), set `PUBLIC_BASE_URL=http://localhost:3000` in your `.env.remote`. ## Run desktop with relay tunnel (optional) To test relay/tunnel mode end-to-end: ```bash export VK_SHARED_API_BASE=https://localhost:3001 export VK_SHARED_RELAY_API_BASE=https://relay.localhost:3001 pnpm run dev ``` Quick checks: ```bash curl -sk https://localhost:3001/v1/health curl -sk https://relay.localhost:3001/health ``` If `https://relay.localhost:3001/health` returns the remote frontend HTML instead of `{"status":"ok"}`, your Caddy host routing is incorrect. ================================================ FILE: crates/remote/docker-compose.yml ================================================ # Self-hosting: set PUBLIC_BASE_URL to your public URL (e.g. https://kanban.example.com) # and REMOTE_SERVER_PORTS=0.0.0.0:3000:8081 so the server is reachable from other hosts. services: remote-db: image: postgres:16-alpine command: [ "postgres", "-c", "wal_level=logical" ] environment: POSTGRES_DB: remote POSTGRES_USER: remote POSTGRES_PASSWORD: remote volumes: - remote-db-data:/var/lib/postgresql/data healthcheck: test: [ "CMD-SHELL", "pg_isready -U remote -d remote" ] interval: 5s timeout: 5s retries: 5 start_period: 5s ports: - "5433:5432" azurite: image: mcr.microsoft.com/azure-storage/azurite:latest command: "azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --loose --skipApiVersionCheck" ports: - "10000:10000" volumes: - azurite-data:/data healthcheck: test: nc 127.0.0.1 10000 -z interval: 1s retries: 30 azurite-init: image: mcr.microsoft.com/azure-cli:latest depends_on: azurite: condition: service_healthy environment: AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;" entrypoint: /bin/sh command: - -c - | set -e az storage container create --name issue-attachments 2>/dev/null || true az storage cors add --services b --methods GET PUT POST DELETE OPTIONS --origins '*' --allowed-headers '*' --exposed-headers '*' --max-age 3600 echo "Azurite CORS configured" electric: image: electricsql/electric:1.4.13 working_dir: /app restart: on-failure environment: DATABASE_URL: postgresql://electric_sync:${ELECTRIC_ROLE_PASSWORD:-remote}@remote-db:5432/remote?sslmode=disable PG_PROXY_PORT: 65432 LOGICAL_PUBLISHER_HOST: electric AUTH_MODE: insecure ELECTRIC_INSECURE: true ELECTRIC_MANUAL_TABLE_PUBLISHING: true ELECTRIC_USAGE_REPORTING: false ELECTRIC_FEATURE_FLAGS: allow_subqueries,tagged_subqueries volumes: - electric-data:/app/persistent depends_on: remote-db: condition: service_healthy remote-server: condition: service_healthy remote-server: build: context: ../.. dockerfile: crates/remote/Dockerfile args: FEATURES: ${FEATURES:-} POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_API_ENDPOINT: ${POSTHOG_API_ENDPOINT:-} SENTRY_DSN_REMOTE: ${SENTRY_DSN_REMOTE:-} VITE_RELAY_API_BASE_URL: ${VITE_RELAY_API_BASE_URL:-http://localhost:8082} VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY: ${VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY:-} ssh: - default depends_on: remote-db: condition: service_healthy azurite-init: condition: service_completed_successfully environment: RUST_LOG: info,remote=info SERVER_DATABASE_URL: postgres://remote:remote@remote-db:5432/remote SERVER_LISTEN_ADDR: 0.0.0.0:8081 ELECTRIC_URL: http://electric:3000 # OAuth — configure at least one provider (GitHub or Google) GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET:?set in .env.remote} # Optional — leave empty to disable invitation emails LOOPS_EMAIL_API_KEY: ${LOOPS_EMAIL_API_KEY:-} DIGEST_ENABLED: ${DIGEST_ENABLED:-false} SERVER_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000} ELECTRIC_ROLE_PASSWORD: ${ELECTRIC_ROLE_PASSWORD:-remote} R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} R2_REVIEW_ENDPOINT: ${R2_REVIEW_ENDPOINT:-} R2_REVIEW_BUCKET: ${R2_REVIEW_BUCKET:-} REVIEW_WORKER_BASE_URL: ${REVIEW_WORKER_BASE_URL:-} GITHUB_APP_ID: ${GITHUB_APP_ID:-} GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-} GITHUB_APP_WEBHOOK_SECRET: ${GITHUB_APP_WEBHOOK_SECRET:-} GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_TEAM_SEAT_PRICE_ID: ${STRIPE_TEAM_SEAT_PRICE_ID:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_FREE_SEAT_LIMIT: ${STRIPE_FREE_SEAT_LIMIT:-1} AZURE_STORAGE_ACCOUNT_NAME: ${AZURE_STORAGE_ACCOUNT_NAME:-devstoreaccount1} AZURE_STORAGE_ACCOUNT_KEY: ${AZURE_STORAGE_ACCOUNT_KEY:-Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==} AZURE_STORAGE_CONTAINER_NAME: ${AZURE_STORAGE_CONTAINER_NAME:-issue-attachments} AZURE_STORAGE_ENDPOINT_URL: ${AZURE_STORAGE_ENDPOINT_URL:-http://azurite:10000/devstoreaccount1} AZURE_STORAGE_PUBLIC_ENDPOINT_URL: ${AZURE_STORAGE_PUBLIC_ENDPOINT_URL:-http://localhost:10000/devstoreaccount1} ports: - "${REMOTE_SERVER_PORTS:-127.0.0.1:3000:8081}" restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8081/v1/health"] interval: 5s timeout: 5s retries: 10 start_period: 10s relay-server: build: context: ../.. dockerfile: crates/relay-tunnel/Dockerfile depends_on: remote-db: condition: service_healthy environment: RUST_LOG: info SERVER_DATABASE_URL: postgres://remote:remote@remote-db:5432/remote RELAY_LISTEN_ADDR: 0.0.0.0:8082 VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET:?set in .env.remote} ports: - "127.0.0.1:8082:8082" restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8082/health"] interval: 5s timeout: 5s retries: 10 start_period: 10s volumes: remote-db-data: electric-data: azurite-data: ================================================ FILE: crates/remote/migrations/20251001000000_shared_tasks_activity.sql ================================================ CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; CREATE TABLE IF NOT EXISTS organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, is_personal BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, first_name TEXT, last_name TEXT, username TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); DO $$ BEGIN CREATE TYPE member_role AS ENUM ('admin', 'member'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS organization_member_metadata ( organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role member_role NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen_at TIMESTAMPTZ, PRIMARY KEY (organization_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_member_metadata_user ON organization_member_metadata (user_id); CREATE INDEX IF NOT EXISTS idx_member_metadata_org_role ON organization_member_metadata (organization_id, role); DO $$ BEGIN CREATE TYPE task_status AS ENUM ('todo', 'in-progress', 'in-review', 'done', 'cancelled'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, name TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_projects_org_name ON projects (organization_id, name); CREATE TABLE IF NOT EXISTS project_activity_counters ( project_id UUID PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, last_seq BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS shared_tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, creator_user_id UUID REFERENCES users(id) ON DELETE SET NULL, assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, deleted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, title TEXT NOT NULL, description TEXT, status task_status NOT NULL DEFAULT 'todo'::task_status, version BIGINT NOT NULL DEFAULT 1, deleted_at TIMESTAMPTZ, shared_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_tasks_org_status ON shared_tasks (organization_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_org_assignee ON shared_tasks (organization_id, assignee_user_id); CREATE INDEX IF NOT EXISTS idx_tasks_project ON shared_tasks (project_id); CREATE INDEX IF NOT EXISTS idx_shared_tasks_org_deleted_at ON shared_tasks (organization_id, deleted_at) WHERE deleted_at IS NOT NULL; -- Partitioned activity feed (24-hour range partitions on created_at). CREATE TABLE activity ( seq BIGINT NOT NULL, event_id UUID NOT NULL DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, event_type TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (created_at, project_id, seq), UNIQUE (created_at, event_id) ) PARTITION BY RANGE (created_at); CREATE INDEX IF NOT EXISTS idx_activity_project_seq ON activity (project_id, seq DESC); -- Create partitions on demand for the 24-hour window that contains target_ts. CREATE FUNCTION ensure_activity_partition(target_ts TIMESTAMPTZ) RETURNS VOID LANGUAGE plpgsql AS $$ DECLARE bucket_seconds CONSTANT INTEGER := 24 * 60 * 60; bucket_start TIMESTAMPTZ; bucket_end TIMESTAMPTZ; partition_name TEXT; BEGIN bucket_start := to_timestamp( floor(EXTRACT(EPOCH FROM target_ts) / bucket_seconds) * bucket_seconds ); bucket_end := bucket_start + INTERVAL '24 hours'; partition_name := format( 'activity_p_%s', to_char(bucket_start AT TIME ZONE 'UTC', 'YYYYMMDD') ); BEGIN EXECUTE format( 'CREATE TABLE IF NOT EXISTS %I PARTITION OF activity FOR VALUES FROM (%L) TO (%L)', partition_name, bucket_start, bucket_end ); EXCEPTION WHEN duplicate_table THEN NULL; END; END; $$; -- Seed partitions for the current and next 2 days (48 hours) for safety. -- This ensures partitions exist even if cron job fails temporarily. SELECT ensure_activity_partition(NOW()); SELECT ensure_activity_partition(NOW() + INTERVAL '24 hours'); SELECT ensure_activity_partition(NOW() + INTERVAL '48 hours'); DO $$ BEGIN DROP TRIGGER IF EXISTS trg_activity_notify ON activity; EXCEPTION WHEN undefined_object THEN NULL; END $$; DO $$ BEGIN DROP FUNCTION IF EXISTS activity_notify(); EXCEPTION WHEN undefined_function THEN NULL; END $$; CREATE FUNCTION activity_notify() RETURNS trigger AS $$ BEGIN PERFORM pg_notify( 'activity', json_build_object( 'seq', NEW.seq, 'event_id', NEW.event_id, 'project_id', NEW.project_id, 'event_type', NEW.event_type, 'created_at', NEW.created_at )::text ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; CREATE TRIGGER trg_activity_notify AFTER INSERT ON activity FOR EACH ROW EXECUTE FUNCTION activity_notify(); DO $$ BEGIN CREATE TYPE invitation_status AS ENUM ('pending', 'accepted', 'declined', 'expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS organization_invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, invited_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, email TEXT NOT NULL, role member_role NOT NULL DEFAULT 'member', status invitation_status NOT NULL DEFAULT 'pending', token TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_org_invites_org ON organization_invitations (organization_id); CREATE INDEX IF NOT EXISTS idx_org_invites_status_expires ON organization_invitations (status, expires_at); CREATE UNIQUE INDEX IF NOT EXISTS uniq_pending_invite_per_email_per_org ON organization_invitations (organization_id, lower(email)) WHERE status = 'pending'; CREATE TABLE IF NOT EXISTS auth_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, session_secret_hash TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_id); CREATE TABLE IF NOT EXISTS oauth_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, provider_user_id TEXT NOT NULL, email TEXT, username TEXT, display_name TEXT, avatar_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (provider, provider_user_id) ); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts (user_id); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_provider_user ON oauth_accounts (provider, provider_user_id); CREATE TABLE IF NOT EXISTS oauth_handoffs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider TEXT NOT NULL, state TEXT NOT NULL, return_to TEXT NOT NULL, app_challenge TEXT NOT NULL, app_code_hash TEXT, status TEXT NOT NULL DEFAULT 'pending', error_code TEXT, expires_at TIMESTAMPTZ NOT NULL, authorized_at TIMESTAMPTZ, redeemed_at TIMESTAMPTZ, user_id UUID REFERENCES users(id), session_id UUID REFERENCES auth_sessions(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_oauth_handoffs_status ON oauth_handoffs (status); CREATE INDEX IF NOT EXISTS idx_oauth_handoffs_user ON oauth_handoffs (user_id); CREATE TRIGGER trg_organizations_updated_at BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_shared_tasks_updated_at BEFORE UPDATE ON shared_tasks FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_org_invites_updated_at BEFORE UPDATE ON organization_invitations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_oauth_accounts_updated_at BEFORE UPDATE ON oauth_accounts FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_oauth_handoffs_updated_at BEFORE UPDATE ON oauth_handoffs FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE OR REPLACE FUNCTION set_last_used_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.last_used_at = NOW(); RETURN NEW; END; $$; CREATE TRIGGER trg_auth_sessions_last_used_at BEFORE UPDATE ON auth_sessions FOR EACH ROW EXECUTE FUNCTION set_last_used_at(); ================================================ FILE: crates/remote/migrations/20251117000000_jwt_refresh_tokens.sql ================================================ ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS refresh_token_id UUID; ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS refresh_token_issued_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_auth_sessions_refresh_id ON auth_sessions (refresh_token_id); CREATE TABLE IF NOT EXISTS revoked_refresh_tokens ( token_id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), revoked_reason TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_refresh_tokens (user_id); ================================================ FILE: crates/remote/migrations/20251120121307_oauth_handoff_tokens.sql ================================================ ALTER TABLE oauth_handoffs ADD COLUMN IF NOT EXISTS encrypted_provider_tokens TEXT; ================================================ FILE: crates/remote/migrations/20251127000000_electric_support.sql ================================================ CREATE ROLE electric_sync WITH LOGIN REPLICATION; GRANT CONNECT ON DATABASE remote TO electric_sync; GRANT USAGE ON SCHEMA public TO electric_sync; CREATE PUBLICATION electric_publication_default; CREATE OR REPLACE FUNCTION electric_sync_table(p_schema text, p_table text) RETURNS void LANGUAGE plpgsql AS $$ DECLARE qualified text := format('%I.%I', p_schema, p_table); BEGIN EXECUTE format('ALTER TABLE %s REPLICA IDENTITY FULL', qualified); EXECUTE format('GRANT SELECT ON TABLE %s TO electric_sync', qualified); EXECUTE format('ALTER PUBLICATION %I ADD TABLE %s', 'electric_publication_default', qualified); END; $$; SELECT electric_sync_table('public', 'shared_tasks'); ================================================ FILE: crates/remote/migrations/20251201000000_drop_unused_activity_and_columns.sql ================================================ -- Drop activity feed tables and functions DROP TABLE IF EXISTS activity CASCADE; DROP TABLE IF EXISTS project_activity_counters; DROP FUNCTION IF EXISTS ensure_activity_partition; DROP FUNCTION IF EXISTS activity_notify; -- Drop unused columns from shared_tasks ALTER TABLE shared_tasks DROP COLUMN IF EXISTS version; ALTER TABLE shared_tasks DROP COLUMN IF EXISTS last_event_seq; ================================================ FILE: crates/remote/migrations/20251201010000_unify_task_status_enums.sql ================================================ ALTER TYPE task_status RENAME VALUE 'in-progress' TO 'inprogress'; ALTER TYPE task_status RENAME VALUE 'in-review' TO 'inreview'; ================================================ FILE: crates/remote/migrations/20251212000000_create_reviews_table.sql ================================================ CREATE TABLE IF NOT EXISTS reviews ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), gh_pr_url TEXT NOT NULL, claude_code_session_id TEXT, ip_address INET NOT NULL, review_cache JSONB, last_viewed_at TIMESTAMPTZ, r2_path TEXT NOT NULL, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), email TEXT NOT NULL, pr_title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' ); -- Index for rate limiting queries (IP + time range) CREATE INDEX IF NOT EXISTS idx_reviews_ip_created ON reviews (ip_address, created_at); ================================================ FILE: crates/remote/migrations/20251215000000_github_app_installations.sql ================================================ -- GitHub App installations linked to organizations CREATE TABLE github_app_installations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, github_installation_id BIGINT NOT NULL UNIQUE, github_account_login TEXT NOT NULL, github_account_type TEXT NOT NULL, -- 'Organization' or 'User' repository_selection TEXT NOT NULL, -- 'all' or 'selected' installed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, suspended_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_github_app_installations_org ON github_app_installations(organization_id); -- Repositories accessible via an installation CREATE TABLE github_app_repositories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), installation_id UUID NOT NULL REFERENCES github_app_installations(id) ON DELETE CASCADE, github_repo_id BIGINT NOT NULL, repo_full_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(installation_id, github_repo_id) ); CREATE INDEX idx_github_app_repos_installation ON github_app_repositories(installation_id); -- Track pending installations (before callback completes) CREATE TABLE github_app_pending_installations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, state_token TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_pending_installations_state ON github_app_pending_installations(state_token); CREATE INDEX idx_pending_installations_expires ON github_app_pending_installations(expires_at); ================================================ FILE: crates/remote/migrations/20251216000000_add_webhook_fields_to_reviews.sql ================================================ -- Make email and ip_address nullable for webhook-triggered reviews ALTER TABLE reviews ALTER COLUMN email DROP NOT NULL, ALTER COLUMN ip_address DROP NOT NULL; -- Add webhook-specific columns ALTER TABLE reviews ADD COLUMN github_installation_id BIGINT, ADD COLUMN pr_owner TEXT, ADD COLUMN pr_repo TEXT, ADD COLUMN pr_number INTEGER; -- Index for webhook reviews CREATE INDEX idx_reviews_webhook ON reviews (github_installation_id) WHERE github_installation_id IS NOT NULL; ================================================ FILE: crates/remote/migrations/20251216100000_add_review_enabled_to_repos.sql ================================================ -- Add review_enabled column to allow users to toggle which repos are reviewed ALTER TABLE github_app_repositories ADD COLUMN review_enabled BOOLEAN NOT NULL DEFAULT true; -- Index for efficient filtering during webhook processing CREATE INDEX idx_github_app_repos_review_enabled ON github_app_repositories(installation_id, review_enabled) WHERE review_enabled = true; ================================================ FILE: crates/remote/migrations/20260112000000_remote-projects.sql ================================================ -- 0. DROP SHARED TASKS -- Remove the old shared_tasks table and related objects DROP TABLE IF EXISTS shared_tasks CASCADE; DROP TYPE IF EXISTS task_status; -- 1. ENUMS -- We define enums for fields with a fixed set of options CREATE TYPE issue_priority AS ENUM ('urgent', 'high', 'medium', 'low'); -- 2. MODIFY EXISTING ORGANIZATIONS TABLE -- Add issue_prefix for simple IDs (e.g., "BLO" from "Bloop") ALTER TABLE organizations ADD COLUMN IF NOT EXISTS issue_prefix VARCHAR(10) NOT NULL DEFAULT 'ISS'; -- 3. MODIFY EXISTING PROJECTS TABLE -- Add color and updated_at columns, drop unused metadata column ALTER TABLE projects ADD COLUMN IF NOT EXISTS color VARCHAR(20) NOT NULL DEFAULT '0 0% 0%'; ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); ALTER TABLE projects DROP COLUMN IF EXISTS metadata; -- Add issue_counter for sequential issue numbering per project ALTER TABLE projects ADD COLUMN IF NOT EXISTS issue_counter INTEGER NOT NULL DEFAULT 0; -- Add updated_at trigger for projects CREATE TRIGGER trg_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- 4. PROJECT STATUSES -- Configurable statuses per project (Backlog, Todo, etc.) CREATE TABLE project_statuses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name VARCHAR(50) NOT NULL, color VARCHAR(20) NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, hidden BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- 6. PROJECT NOTIFICATION PREFERENCES CREATE TABLE project_notification_preferences ( project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, notify_on_issue_created BOOLEAN NOT NULL DEFAULT TRUE, notify_on_issue_assigned BOOLEAN NOT NULL DEFAULT TRUE, PRIMARY KEY (project_id, user_id) ); -- 6. ISSUES CREATE TABLE issues ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -- Simple ID fields (e.g., "BLO-5") issue_number INTEGER NOT NULL, simple_id VARCHAR(20) NOT NULL, -- Status inherits from project_statuses status_id UUID NOT NULL REFERENCES project_statuses(id), title VARCHAR(255) NOT NULL, description TEXT, priority issue_priority, start_date TIMESTAMPTZ, target_date TIMESTAMPTZ, -- Completion status completed_at TIMESTAMPTZ, -- NULL means not completed -- Ordering in lists/kanban sort_order DOUBLE PRECISION NOT NULL DEFAULT 0, -- Parent Issue (Self-referential) parent_issue_id UUID REFERENCES issues(id) ON DELETE SET NULL, parent_issue_sort_order DOUBLE PRECISION, -- Extension Metadata (JSONB for flexibility) extension_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Ensure unique issue numbers per project CONSTRAINT issues_project_issue_number_uniq UNIQUE (project_id, issue_number) ); -- Trigger function to auto-generate issue_number and simple_id CREATE OR REPLACE FUNCTION set_issue_simple_id() RETURNS TRIGGER AS $$ DECLARE v_issue_number INTEGER; v_issue_prefix VARCHAR(10); BEGIN -- Atomically increment the project's issue_counter and get the new number UPDATE projects SET issue_counter = issue_counter + 1 WHERE id = NEW.project_id RETURNING issue_counter INTO v_issue_number; -- Get the organization's issue_prefix SELECT o.issue_prefix INTO v_issue_prefix FROM projects p JOIN organizations o ON o.id = p.organization_id WHERE p.id = NEW.project_id; -- Set the issue_number and simple_id NEW.issue_number := v_issue_number; NEW.simple_id := v_issue_prefix || '-' || v_issue_number; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_issues_simple_id BEFORE INSERT ON issues FOR EACH ROW EXECUTE FUNCTION set_issue_simple_id(); -- 9. ISSUE ASSIGNEES (Team members) CREATE TABLE issue_assignees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (issue_id, user_id) ); -- 10. ISSUE FOLLOWERS CREATE TABLE issue_followers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, UNIQUE (issue_id, user_id) ); -- 11. ISSUE RELATIONSHIPS -- Links issues with different relationship types (blocking, related, duplicate) CREATE TYPE issue_relationship_type AS ENUM ('blocking', 'related', 'has_duplicate'); CREATE TABLE issue_relationships ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, related_issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, relationship_type issue_relationship_type NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (issue_id, related_issue_id, relationship_type), CHECK (issue_id != related_issue_id) ); -- 12. TAGS CREATE TABLE tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name VARCHAR(50) NOT NULL, color VARCHAR(20) NOT NULL, UNIQUE (project_id, name) ); CREATE TABLE issue_tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, UNIQUE (issue_id, tag_id) ); -- 13. COMMENTS CREATE TABLE issue_comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, author_id UUID REFERENCES users(id) ON DELETE SET NULL, parent_id UUID REFERENCES issue_comments(id) ON DELETE SET NULL, message TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- 14. COMMENT REACTIONS CREATE TABLE issue_comment_reactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), comment_id UUID NOT NULL REFERENCES issue_comments(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, emoji VARCHAR(32) NOT NULL, -- Store the emoji character or shortcode created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- One reaction type per user per comment UNIQUE (comment_id, user_id, emoji) ); -- 15. NOTIFICATIONS CREATE TYPE notification_type AS ENUM ( 'issue_comment_added', 'issue_status_changed', 'issue_assignee_changed', 'issue_deleted' ); CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, notification_type notification_type NOT NULL, payload JSONB NOT NULL DEFAULT '{}', issue_id UUID REFERENCES issues(id) ON DELETE SET NULL, comment_id UUID REFERENCES issue_comments(id) ON DELETE SET NULL, seen BOOLEAN NOT NULL DEFAULT FALSE, dismissed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Indexes for common lookups CREATE INDEX idx_issues_project_id ON issues(project_id); CREATE INDEX idx_issues_status_id ON issues(status_id); CREATE INDEX idx_issues_parent_issue_id ON issues(parent_issue_id); CREATE INDEX idx_issues_simple_id ON issues(simple_id); CREATE INDEX idx_issue_comments_issue_id ON issue_comments(issue_id); CREATE INDEX idx_issue_comments_parent_id ON issue_comments(parent_id); CREATE INDEX idx_notifications_user_unseen ON notifications (user_id, seen) WHERE dismissed_at IS NULL; CREATE INDEX idx_notifications_user_created ON notifications (user_id, created_at DESC); CREATE INDEX idx_notifications_org ON notifications (organization_id); -- 16. WORKSPACES -- Workspace metadata pushed from local clients CREATE TABLE workspaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, issue_id UUID REFERENCES issues(id) ON DELETE SET NULL, local_workspace_id UUID UNIQUE, name TEXT, archived BOOLEAN NOT NULL DEFAULT FALSE, files_changed INTEGER, lines_added INTEGER, lines_removed INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_workspaces_project_id ON workspaces(project_id); CREATE INDEX idx_workspaces_owner_user_id ON workspaces(owner_user_id); CREATE INDEX idx_workspaces_issue_id ON workspaces(issue_id) WHERE issue_id IS NOT NULL; CREATE INDEX idx_workspaces_local_workspace_id ON workspaces(local_workspace_id); -- 17. PULL REQUESTS -- Direct PR tracking linked to issues (tasks) CREATE TYPE pull_request_status AS ENUM ('open', 'merged', 'closed'); CREATE TABLE pull_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), url TEXT NOT NULL, number INTEGER NOT NULL, status pull_request_status NOT NULL DEFAULT 'open', merged_at TIMESTAMPTZ, merge_commit_sha VARCHAR(40), target_branch_name TEXT NOT NULL, issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (url) ); CREATE INDEX idx_pull_requests_issue_id ON pull_requests(issue_id); CREATE INDEX idx_pull_requests_workspace_id ON pull_requests(workspace_id) WHERE workspace_id IS NOT NULL; CREATE INDEX idx_pull_requests_status ON pull_requests(status); ================================================ FILE: crates/remote/migrations/20260114000000_electric_sync_tables.sql ================================================ -- Add tables to Electric publication for sync -- These tables need REPLICA IDENTITY FULL for Electric to track changes SELECT electric_sync_table('public', 'users'); SELECT electric_sync_table('public', 'projects'); SELECT electric_sync_table('public', 'project_statuses'); SELECT electric_sync_table('public', 'tags'); SELECT electric_sync_table('public', 'issues'); SELECT electric_sync_table('public', 'issue_assignees'); SELECT electric_sync_table('public', 'issue_followers'); SELECT electric_sync_table('public', 'issue_tags'); SELECT electric_sync_table('public', 'issue_comments'); SELECT electric_sync_table('public', 'issue_relationships'); SELECT electric_sync_table('public', 'issue_comment_reactions'); SELECT electric_sync_table('public', 'notifications'); SELECT electric_sync_table('public', 'organization_member_metadata'); SELECT electric_sync_table('public', 'workspaces'); SELECT electric_sync_table('public', 'pull_requests'); -- Add indexes for subquery performance CREATE INDEX IF NOT EXISTS idx_projects_organization_id ON projects(organization_id); CREATE INDEX IF NOT EXISTS idx_project_statuses_project_id ON project_statuses(project_id); CREATE INDEX IF NOT EXISTS idx_tags_project_id ON tags(project_id); CREATE INDEX IF NOT EXISTS idx_issue_assignees_issue_id ON issue_assignees(issue_id); CREATE INDEX IF NOT EXISTS idx_issue_followers_issue_id ON issue_followers(issue_id); CREATE INDEX IF NOT EXISTS idx_issue_tags_issue_id ON issue_tags(issue_id); CREATE INDEX IF NOT EXISTS idx_issue_relationships_issue_id ON issue_relationships(issue_id); CREATE INDEX IF NOT EXISTS idx_issue_comment_reactions_comment_id ON issue_comment_reactions(comment_id); ================================================ FILE: crates/remote/migrations/20260115000000_billing.sql ================================================ -- Organization billing records for Stripe subscriptions CREATE TABLE organization_billing ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, -- Stripe identifiers stripe_customer_id TEXT, stripe_subscription_id TEXT UNIQUE, stripe_subscription_item_id TEXT, -- Subscription status: 'active', 'past_due', 'canceled', 'incomplete', etc. subscription_status TEXT, -- Billing period current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, -- Number of seats in subscription quantity INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Index for looking up by Stripe customer CREATE INDEX idx_org_billing_stripe_customer ON organization_billing(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; -- Index for looking up by Stripe subscription CREATE INDEX idx_org_billing_stripe_subscription ON organization_billing(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; -- Trigger to update updated_at on modification CREATE TRIGGER trg_organization_billing_updated_at BEFORE UPDATE ON organization_billing FOR EACH ROW EXECUTE FUNCTION set_updated_at(); ================================================ FILE: crates/remote/migrations/20260204000000_issue_attachments.sql ================================================ -- Blobs: actual file storage metadata (one per unique file) -- Supports deduplication: same file content (hash) can be shared across attachments CREATE TABLE blobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, blob_path TEXT NOT NULL UNIQUE, -- Azure blob path for original thumbnail_blob_path TEXT, -- Azure blob path for thumbnail (null if not an image) original_name TEXT NOT NULL, -- User-provided filename mime_type TEXT, -- Content type size_bytes BIGINT NOT NULL, hash TEXT NOT NULL, -- SHA256 for deduplication width INT, -- Image width in pixels (null for non-images) height INT, -- Image height in pixels (null for non-images) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_blobs_project_id ON blobs(project_id); CREATE INDEX idx_blobs_hash ON blobs(hash); -- Attachments: links blobs to issues or comments (junction table) -- Supports staging (issue_id = NULL, comment_id = NULL) for uploads before creation CREATE TABLE attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), blob_id UUID NOT NULL REFERENCES blobs(id) ON DELETE CASCADE, issue_id UUID REFERENCES issues(id) ON DELETE CASCADE, comment_id UUID REFERENCES issue_comments(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, -- For cleanup of abandoned staged attachments -- Only one target can be set (or neither for staging) CONSTRAINT attachments_single_target CHECK (NOT (issue_id IS NOT NULL AND comment_id IS NOT NULL)) ); CREATE INDEX idx_attachments_blob_id ON attachments(blob_id); CREATE INDEX idx_attachments_issue_id ON attachments(issue_id) WHERE issue_id IS NOT NULL; CREATE INDEX idx_attachments_comment_id ON attachments(comment_id) WHERE comment_id IS NOT NULL; CREATE INDEX idx_attachments_expires_at ON attachments(expires_at) WHERE expires_at IS NOT NULL; -- Enable Electric sync for real-time updates SELECT electric_sync_table('public', 'blobs'); SELECT electric_sync_table('public', 'attachments'); ================================================ FILE: crates/remote/migrations/20260205000000_add_issue_creator.sql ================================================ -- Add creator_user_id to issues table to track who created each issue ALTER TABLE issues ADD COLUMN creator_user_id UUID REFERENCES users(id) ON DELETE SET NULL; -- Index for efficient queries filtering by creator CREATE INDEX idx_issues_creator_user_id ON issues(creator_user_id) WHERE creator_user_id IS NOT NULL; ================================================ FILE: crates/remote/migrations/20260213000000_pending_uploads.sql ================================================ CREATE TABLE pending_uploads ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, blob_path TEXT NOT NULL, hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_pending_uploads_expires_at ON pending_uploads(expires_at); ================================================ FILE: crates/remote/migrations/20260216000000_remove_attachment_electric_sync.sql ================================================ -- Remove Electric sync for blobs and attachments (shapes are no longer used) ALTER PUBLICATION electric_publication_default DROP TABLE public.blobs; REVOKE SELECT ON TABLE public.blobs FROM electric_sync; ALTER TABLE public.blobs REPLICA IDENTITY DEFAULT; ALTER PUBLICATION electric_publication_default DROP TABLE public.attachments; REVOKE SELECT ON TABLE public.attachments FROM electric_sync; ALTER TABLE public.attachments REPLICA IDENTITY DEFAULT; ================================================ FILE: crates/remote/migrations/20260217000000_add_project_sort_order.sql ================================================ ALTER TABLE projects ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0; CREATE INDEX IF NOT EXISTS idx_projects_organization_sort_order ON projects (organization_id, sort_order); ================================================ FILE: crates/remote/migrations/20260226000000_add_encrypted_provider_tokens_to_oauth_accounts.sql ================================================ ALTER TABLE oauth_accounts ADD COLUMN IF NOT EXISTS encrypted_provider_tokens TEXT; ================================================ FILE: crates/remote/migrations/20260226100000_relay_hosts_and_sessions.sql ================================================ CREATE TABLE hosts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with_organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL, machine_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('offline', 'online')), last_seen_at TIMESTAMPTZ, agent_version TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_hosts_owner_user_id ON hosts(owner_user_id); CREATE UNIQUE INDEX idx_hosts_owner_user_id_machine_id ON hosts(owner_user_id, machine_id); CREATE INDEX idx_hosts_shared_with_organization_id ON hosts(shared_with_organization_id); CREATE INDEX idx_hosts_last_seen_at ON hosts(last_seen_at DESC); CREATE TABLE relay_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, request_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, state TEXT NOT NULL CHECK (state IN ('requested', 'active', 'expired')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, claimed_at TIMESTAMPTZ, ended_at TIMESTAMPTZ ); CREATE INDEX idx_relay_sessions_host_id ON relay_sessions(host_id); CREATE INDEX idx_relay_sessions_request_user_id ON relay_sessions(request_user_id); CREATE INDEX idx_relay_sessions_state_expires_at ON relay_sessions(state, expires_at); CREATE TABLE relay_browser_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, auth_session_id UUID NOT NULL REFERENCES auth_sessions(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ ); CREATE INDEX idx_relay_browser_sessions_host_id ON relay_browser_sessions(host_id); CREATE INDEX idx_relay_browser_sessions_user_id ON relay_browser_sessions(user_id); CREATE INDEX idx_relay_browser_sessions_auth_session_id ON relay_browser_sessions(auth_session_id); CREATE INDEX idx_relay_browser_sessions_active ON relay_browser_sessions(host_id, user_id) WHERE revoked_at IS NULL; CREATE TABLE relay_auth_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code_hash TEXT NOT NULL UNIQUE, host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, relay_cookie_value TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_relay_auth_codes_host_id ON relay_auth_codes(host_id); CREATE INDEX idx_relay_auth_codes_expires_at ON relay_auth_codes(expires_at); ================================================ FILE: crates/remote/migrations/20260310000000_add_title_description_notification_types.sql ================================================ ALTER TYPE notification_type ADD VALUE 'issue_title_changed'; ALTER TYPE notification_type ADD VALUE 'issue_description_changed'; ALTER TYPE notification_type ADD VALUE 'issue_priority_changed'; ALTER TYPE notification_type ADD VALUE 'issue_unassigned'; ALTER TYPE notification_type ADD VALUE 'issue_comment_reaction'; ================================================ FILE: crates/remote/migrations/20260311000000_notification_digest.sql ================================================ CREATE TABLE notification_digest_deliveries ( notification_id UUID PRIMARY KEY REFERENCES notifications(id) ON DELETE CASCADE, sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ================================================ FILE: crates/remote/migrations/20260313000000_fix-short-id-counter.sql ================================================ -- Fix short IDs to be unique per org, not per project. -- Moves issue_counter from projects -> organizations so that issues -- across all projects in an org share a single incrementing counter. -- e.g., Project A issue 1 gets ORG-1, Project B issue 1 gets ORG-2. -- Uniqueness is enforced by the trigger (atomic counter increment), not a constraint. -- 1. Add org-level counter ALTER TABLE organizations ADD COLUMN IF NOT EXISTS issue_counter INTEGER NOT NULL DEFAULT 0; -- 2. Renumber all existing issues with org-wide sequential numbers. -- Drop the old per-project uniqueness constraint first: the bulk UPDATE can -- otherwise hit transient (project_id, issue_number) collisions mid-statement -- before every row has been reassigned. ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_project_issue_number_uniq; -- 3. Renumber all existing issues with org-wide sequential numbers. -- Under the old schema, issue_number was per-project (each project starts at 1), -- so multiple projects in the same org have overlapping numbers and duplicate -- simple_ids (e.g. both Project A and Project B show ORG-1). Reassign sequential -- numbers ordered by created_at (id as tiebreaker) and update simple_id to match. WITH renumbered AS ( SELECT i.id, ROW_NUMBER() OVER ( PARTITION BY p.organization_id ORDER BY i.created_at, i.id ) AS new_issue_number, o.issue_prefix FROM issues i JOIN projects p ON p.id = i.project_id JOIN organizations o ON o.id = p.organization_id ) UPDATE issues i SET issue_number = r.new_issue_number, simple_id = r.issue_prefix || '-' || r.new_issue_number FROM renumbered r WHERE i.id = r.id; -- 4. Backfill denormalized notification payloads that store issue_simple_id. UPDATE notifications n SET payload = jsonb_set(n.payload, '{issue_simple_id}', to_jsonb(i.simple_id), true) FROM issues i WHERE n.issue_id = i.id AND n.payload ? 'issue_simple_id'; -- 5. Set org counters to the maximum issue_number now assigned. UPDATE organizations o SET issue_counter = COALESCE( ( SELECT MAX(i.issue_number) FROM issues i JOIN projects p ON p.id = i.project_id WHERE p.organization_id = o.id ), 0 ); -- 6. Update the trigger function to increment the org counter instead of project counter. -- The trigger trg_issues_simple_id itself does not need to be recreated. -- Uniqueness is guaranteed by the atomic UPDATE ... RETURNING on the org row, -- which serializes concurrent inserts via row-level locking. CREATE OR REPLACE FUNCTION set_issue_simple_id() RETURNS TRIGGER AS $$ DECLARE v_issue_number INTEGER; v_issue_prefix VARCHAR(10); v_organization_id UUID; BEGIN -- Resolve organization and its prefix from the project SELECT p.organization_id, o.issue_prefix INTO v_organization_id, v_issue_prefix FROM projects p JOIN organizations o ON o.id = p.organization_id WHERE p.id = NEW.project_id; -- Atomically increment the organization's counter and capture the new value UPDATE organizations SET issue_counter = issue_counter + 1 WHERE id = v_organization_id RETURNING issue_counter INTO v_issue_number; -- Assign auto-generated fields NEW.issue_number := v_issue_number; NEW.simple_id := v_issue_prefix || '-' || v_issue_number; RETURN NEW; END; $$ LANGUAGE plpgsql; -- 7. Remove the now-unused per-project issue counter ALTER TABLE projects DROP COLUMN IF EXISTS issue_counter; ================================================ FILE: crates/remote/scripts/prepare-db.sh ================================================ #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REMOTE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # Parse arguments BILLING_MANIFEST_PATH="" CHECK_MODE="" for arg in "$@"; do case "$arg" in --check) CHECK_MODE="--check" ;; *) BILLING_MANIFEST_PATH="$arg" ;; esac done # Convert relative paths to absolute (relative to repo root, since pnpm runs from there) BILLING_DIR="" if [ -n "$BILLING_MANIFEST_PATH" ]; then if [[ "$BILLING_MANIFEST_PATH" != /* ]]; then REPO_ROOT="$(cd "$REMOTE_DIR/../.." && pwd)" BILLING_MANIFEST_PATH="$REPO_ROOT/$BILLING_MANIFEST_PATH" fi if [ -f "$BILLING_MANIFEST_PATH" ]; then BILLING_DIR="$(cd "$(dirname "$BILLING_MANIFEST_PATH")" && pwd)" else echo "⚠️ Billing manifest not found: $BILLING_MANIFEST_PATH (skipping billing)" >&2 fi fi # For --check mode, run offline without database (just verify .sqlx cache) if [ "$CHECK_MODE" = "--check" ]; then if [ -n "$BILLING_DIR" ]; then echo "➤ Checking SQLx data for billing (offline mode)..." (cd "$BILLING_DIR" && SQLX_OFFLINE=true cargo sqlx prepare --check) fi echo "➤ Checking SQLx data for remote (offline mode)..." SQLX_OFFLINE=true cargo sqlx prepare --check RELAY_TUNNEL_DIR="$(cd "$REMOTE_DIR/../../crates/relay-tunnel" && pwd)" echo "➤ Checking SQLx data for relay-tunnel (offline mode)..." (cd "$RELAY_TUNNEL_DIR" && SQLX_OFFLINE=true cargo sqlx prepare --check -- --features server) echo "✅ sqlx check complete" exit 0 fi # For prepare mode, need a running PostgreSQL instance DATA_DIR="$(mktemp -d /tmp/sqlxpg.XXXXXX)" PORT=54329 echo "Killing existing Postgres instance on port $PORT" pids=$(lsof -t -i :"$PORT" 2>/dev/null || true) [ -n "$pids" ] && kill $pids 2>/dev/null || true sleep 1 echo "➤ Initializing temporary Postgres cluster..." initdb -D "$DATA_DIR" > /dev/null echo "➤ Starting Postgres on port $PORT..." pg_ctl -D "$DATA_DIR" -o "-p $PORT" -w start > /dev/null echo "➤ Creating 'remote' database..." createdb -p $PORT remote # Connection string export DATABASE_URL="postgres://localhost:$PORT/remote" echo "➤ Running migrations..." sqlx migrate run if [ -n "$BILLING_DIR" ]; then echo "➤ Preparing SQLx data for billing..." (cd "$BILLING_DIR" && cargo sqlx prepare) fi echo "➤ Preparing SQLx data for remote..." cargo sqlx prepare RELAY_TUNNEL_DIR="$(cd "$REMOTE_DIR/../../crates/relay-tunnel" && pwd)" echo "➤ Preparing SQLx data for relay-tunnel..." (cd "$RELAY_TUNNEL_DIR" && cargo sqlx prepare -- --features server) echo "➤ Stopping Postgres..." pg_ctl -D "$DATA_DIR" -m fast -w stop > /dev/null echo "➤ Cleaning up..." rm -rf "$DATA_DIR" echo "Killing existing Postgres instance on port $PORT" pids=$(lsof -t -i :"$PORT" 2>/dev/null || true) [ -n "$pids" ] && kill $pids 2>/dev/null || true sleep 1 echo "✅ sqlx prepare complete" ================================================ FILE: crates/remote/src/analytics.rs ================================================ use std::time::Duration; use serde_json::{Value, json}; use uuid::Uuid; #[derive(Debug, Clone)] pub struct AnalyticsConfig { pub posthog_api_key: String, pub posthog_api_endpoint: String, } impl AnalyticsConfig { pub fn from_env() -> Option { Self::from_values( option_env!("POSTHOG_API_KEY"), option_env!("POSTHOG_API_ENDPOINT"), ) } fn from_values(api_key: Option<&str>, api_endpoint: Option<&str>) -> Option { let api_key = api_key?.trim(); let api_endpoint = api_endpoint?.trim(); if api_key.is_empty() || api_endpoint.is_empty() { return None; } Some(Self { posthog_api_key: api_key.to_string(), posthog_api_endpoint: api_endpoint.to_string(), }) } } #[derive(Clone, Debug)] pub struct AnalyticsService { config: AnalyticsConfig, client: reqwest::Client, } impl AnalyticsService { pub fn new(config: AnalyticsConfig) -> Self { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .expect("failed to build analytics HTTP client"); Self { config, client } } pub fn track(&self, user_id: Uuid, event_name: &str, properties: Value) { let endpoint = format!( "{}/capture/", self.config.posthog_api_endpoint.trim_end_matches('/') ); let payload = if event_name == "$identify" { json!({ "api_key": self.config.posthog_api_key, "event": event_name, "distinct_id": user_id.to_string(), "$set": properties, }) } else { let mut event_properties = properties; if let Some(props) = event_properties.as_object_mut() { props.insert( "timestamp".to_string(), json!(chrono::Utc::now().to_rfc3339()), ); props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); props.insert("source".to_string(), json!("remote")); } json!({ "api_key": self.config.posthog_api_key, "event": event_name, "distinct_id": user_id.to_string(), "properties": event_properties, }) }; let client = self.client.clone(); let event_name = event_name.to_string(); tokio::spawn(async move { match client .post(&endpoint) .header("Content-Type", "application/json") .json(&payload) .send() .await { Ok(response) if !response.status().is_success() => { tracing::warn!( event = %event_name, status = %response.status(), "analytics event failed" ); } Err(e) => { tracing::warn!(event = %event_name, error = ?e, "analytics request failed"); } _ => {} } }); } } ================================================ FILE: crates/remote/src/app.rs ================================================ use std::{net::SocketAddr, sync::Arc}; use anyhow::{Context, bail}; use secrecy::ExposeSecret; use tracing::instrument; use crate::{ AppState, analytics::{AnalyticsConfig, AnalyticsService}, attachments::cleanup::spawn_cleanup_task, auth::{ GitHubOAuthProvider, GoogleOAuthProvider, JwtService, OAuthHandoffService, OAuthTokenValidator, ProviderRegistry, }, azure_blob::AzureBlobService, billing::BillingService, config::RemoteServerConfig, db, digest, github_app::GitHubAppService, mail::{LoopsMailer, Mailer, NoopMailer}, r2::R2Service, routes, }; pub struct Server; impl Server { #[instrument( name = "remote_server", skip(config, billing), fields(listen_addr = %config.listen_addr) )] pub async fn run(config: RemoteServerConfig, billing: BillingService) -> anyhow::Result<()> { let pool = db::create_pool(&config.database_url) .await .context("failed to create postgres pool")?; db::migrate(&pool) .await .context("failed to run database migrations")?; if let Some(password) = config.electric_role_password.as_ref() { db::ensure_electric_role_password(&pool, password.expose_secret()) .await .context("failed to set electric role password")?; } if !config.electric_publication_names.is_empty() { db::electric_publications::ensure_electric_publications( &pool, &config.electric_publication_names, ) .await .context("failed to sync Electric publications")?; } let auth_config = config.auth.clone(); let jwt = Arc::new(JwtService::new(auth_config.jwt_secret().clone())); let mut registry = ProviderRegistry::new(); if let Some(github) = auth_config.github() { registry.register(GitHubOAuthProvider::new( github.client_id().to_string(), github.client_secret().clone(), )?); } if let Some(google) = auth_config.google() { registry.register(GoogleOAuthProvider::new( google.client_id().to_string(), google.client_secret().clone(), )?); } if registry.is_empty() { bail!("no OAuth providers configured"); } let registry = Arc::new(registry); let handoff_service = Arc::new(OAuthHandoffService::new( pool.clone(), registry.clone(), jwt.clone(), auth_config.public_base_url().to_string(), )); let oauth_token_validator = Arc::new(OAuthTokenValidator::new( pool.clone(), registry.clone(), jwt.clone(), )); let loops_email_api_key = std::env::var("LOOPS_EMAIL_API_KEY") .ok() .filter(|api_key| !api_key.is_empty()); let mailer: Arc = match loops_email_api_key.clone() { Some(api_key) => { tracing::info!("Email service (Loops) configured"); Arc::new(LoopsMailer::new(api_key)) } _ => { tracing::info!( "LOOPS_EMAIL_API_KEY not set. Email notifications (invitations, review updates) will be disabled." ); Arc::new(NoopMailer) } }; let server_public_base_url = config.server_public_base_url.clone().ok_or_else(|| { anyhow::anyhow!( "SERVER_PUBLIC_BASE_URL is not set. Please set it in your .env.remote file." ) })?; let r2 = config.r2.as_ref().map(R2Service::new); if r2.is_some() { tracing::info!("R2 storage service initialized"); } else { tracing::warn!( "R2 storage service not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_REVIEW_ENDPOINT, and R2_REVIEW_BUCKET to enable." ); } let azure_blob = config.azure_blob.as_ref().map(AzureBlobService::new); if azure_blob.is_some() { tracing::info!("Azure Blob storage service initialized"); } else { tracing::info!( "Azure Blob storage not configured. Set AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_ACCOUNT_KEY to enable issue attachments." ); } let http_client = reqwest::Client::builder() .user_agent("VibeKanbanRemote/1.0") .build() .context("failed to create HTTP client")?; let github_app = match &config.github_app { Some(github_config) => { match GitHubAppService::new(github_config, http_client.clone()) { Ok(service) => { tracing::info!( app_slug = %github_config.app_slug, "GitHub App service initialized" ); Some(Arc::new(service)) } Err(e) => { tracing::error!(?e, "Failed to initialize GitHub App service"); None } } } None => { tracing::info!( "GitHub App not configured. Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET, and GITHUB_APP_SLUG to enable." ); None } }; if billing.is_configured() { tracing::info!("Billing provider configured"); } else { tracing::info!("Billing provider not configured"); } let analytics = match AnalyticsConfig::from_env() { Some(analytics_config) => { tracing::info!("PostHog analytics configured"); Some(AnalyticsService::new(analytics_config)) } None => { tracing::info!( "PostHog analytics not configured (POSTHOG_API_KEY and/or POSTHOG_API_ENDPOINT not set)" ); None } }; if let Some(ref azure_blob_service) = azure_blob { spawn_cleanup_task(pool.clone(), azure_blob_service.clone()); } let digest_enabled = std::env::var("DIGEST_ENABLED") .map(|v| matches!(v.as_str(), "true" | "1")) .unwrap_or(false); if loops_email_api_key.is_some() && digest_enabled { digest::task::spawn_digest_task( pool.clone(), mailer.clone(), server_public_base_url.clone(), ); } else if !digest_enabled { tracing::info!("Notification digest disabled (feature flag)"); } else { tracing::info!("Notification digest disabled (no email provider configured)"); } let state = AppState::new( pool.clone(), config.clone(), jwt, handoff_service, oauth_token_validator, mailer, server_public_base_url, http_client, r2, azure_blob, github_app, billing, analytics, ); let router = routes::router(state); let addr: SocketAddr = config .listen_addr .parse() .context("listen address is invalid")?; let tcp_listener = tokio::net::TcpListener::bind(addr) .await .context("failed to bind tcp listener")?; tracing::info!(%addr, "shared sync server listening"); let make_service = router.into_make_service(); axum::serve(tcp_listener, make_service) .await .context("shared sync server failure")?; Ok(()) } } ================================================ FILE: crates/remote/src/attachments/cleanup.rs ================================================ use std::time::Duration; use sqlx::PgPool; use tokio::task::JoinHandle; use tracing::{info, instrument, warn}; use crate::{ azure_blob::AzureBlobService, db::{ attachments::AttachmentRepository, blobs::BlobRepository, pending_uploads::PendingUploadRepository, }, }; const EXPIRED_BATCH_SIZE: i64 = 100; const DEFAULT_INTERVAL: Duration = Duration::from_secs(3600); /// Spawns a background task that periodically cleans up orphan attachments and /// expired pending uploads. Call once during server startup. pub fn spawn_cleanup_task(pool: PgPool, azure: AzureBlobService) -> JoinHandle<()> { let interval = std::env::var("ATTACHMENT_CLEANUP_INTERVAL_SECS") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_secs) .unwrap_or(DEFAULT_INTERVAL); info!( interval_secs = interval.as_secs(), "Starting attachment cleanup background task" ); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); // Skip the immediate first tick so the server can finish starting up. ticker.tick().await; loop { ticker.tick().await; run_sweep(&pool, &azure).await; } }) } #[instrument(name = "attachment_cleanup.sweep", skip_all)] async fn run_sweep(pool: &PgPool, azure: &AzureBlobService) { info!("Starting attachment cleanup sweep"); let (expired, pending) = tokio::join!( cleanup_expired_attachments(pool, azure), cleanup_expired_pending_uploads(pool, azure), ); match expired { Ok(count) => info!(deleted = count, "Expired attachment cleanup complete"), Err(e) => warn!(error = %e, "Expired attachment cleanup failed"), } match pending { Ok(count) => info!(deleted = count, "Expired pending uploads cleanup complete"), Err(e) => warn!(error = %e, "Expired pending uploads cleanup failed"), } } async fn cleanup_expired_attachments( pool: &PgPool, azure: &AzureBlobService, ) -> anyhow::Result { let expired = AttachmentRepository::find_expired(pool, EXPIRED_BATCH_SIZE).await?; let mut deleted_count: u32 = 0; for attachment in expired { let attachment_id = attachment.id; let blob_id = attachment.blob_id; if let Err(e) = AttachmentRepository::delete(pool, attachment_id).await { warn!(%attachment_id, error = %e, "Failed to delete expired attachment"); continue; } match AttachmentRepository::count_by_blob_id(pool, blob_id).await { Ok(0) => { if let Ok(Some(blob)) = BlobRepository::delete(pool, blob_id).await { if let Err(e) = azure.delete_blob(&blob.blob_path).await { warn!(blob_path = %blob.blob_path, error = %e, "Failed to delete Azure blob"); } if let Some(thumb_path) = &blob.thumbnail_blob_path && let Err(e) = azure.delete_blob(thumb_path).await { warn!(blob_path = %thumb_path, error = %e, "Failed to delete Azure thumbnail"); } } } Ok(_) => {} // blob still referenced by other attachments Err(e) => { warn!(%blob_id, error = %e, "Failed to count blob references"); } } deleted_count += 1; } Ok(deleted_count) } async fn cleanup_expired_pending_uploads( pool: &PgPool, azure: &AzureBlobService, ) -> anyhow::Result { let expired = PendingUploadRepository::delete_expired(pool).await?; let mut deleted_count: u32 = 0; for pending in expired { if let Err(e) = azure.delete_blob(&pending.blob_path).await { warn!(blob_path = %pending.blob_path, error = %e, "Failed to delete Azure blob for expired pending upload"); } deleted_count += 1; } Ok(deleted_count) } ================================================ FILE: crates/remote/src/attachments/mod.rs ================================================ pub(crate) mod cleanup; pub mod thumbnail; ================================================ FILE: crates/remote/src/attachments/thumbnail.rs ================================================ use image::{DynamicImage, imageops::FilterType}; const THUMBNAIL_MAX_WIDTH: u32 = 200; const THUMBNAIL_MAX_HEIGHT: u32 = 150; const THUMBNAIL_JPEG_QUALITY: u8 = 80; #[derive(Debug)] pub struct ThumbnailResult { pub bytes: Vec, pub width: u32, pub height: u32, pub original_width: u32, pub original_height: u32, pub mime_type: String, } #[derive(Debug, thiserror::Error)] pub enum ThumbnailError { #[error("unsupported image format")] UnsupportedFormat, #[error("image decode error: {0}")] DecodeError(String), #[error("image encode error: {0}")] EncodeError(String), } pub struct ThumbnailService; impl ThumbnailService { /// Generate a thumbnail from image bytes. /// Returns None for non-image MIME types. pub fn generate( data: &[u8], mime_type: Option<&str>, ) -> Result, ThumbnailError> { // Check if it's an image MIME type we support let is_supported_image = mime_type .map(|m| { matches!( m.to_lowercase().as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" ) }) .unwrap_or(false); if !is_supported_image { return Ok(None); } // Decode the image let img = image::load_from_memory(data) .map_err(|e| ThumbnailError::DecodeError(e.to_string()))?; let original_width = img.width(); let original_height = img.height(); // Calculate thumbnail dimensions preserving aspect ratio let (thumb_width, thumb_height) = calculate_thumbnail_dimensions(original_width, original_height); let thumbnail = img.resize(thumb_width, thumb_height, FilterType::Lanczos3); let jpeg_bytes = encode_jpeg(&thumbnail, THUMBNAIL_JPEG_QUALITY)?; Ok(Some(ThumbnailResult { bytes: jpeg_bytes, width: thumb_width, height: thumb_height, original_width, original_height, mime_type: "image/jpeg".to_string(), })) } } /// Calculate thumbnail dimensions preserving aspect ratio. fn calculate_thumbnail_dimensions(width: u32, height: u32) -> (u32, u32) { if width <= THUMBNAIL_MAX_WIDTH && height <= THUMBNAIL_MAX_HEIGHT { return (width, height); } let width_ratio = THUMBNAIL_MAX_WIDTH as f64 / width as f64; let height_ratio = THUMBNAIL_MAX_HEIGHT as f64 / height as f64; let ratio = width_ratio.min(height_ratio); let new_width = (width as f64 * ratio).round() as u32; let new_height = (height as f64 * ratio).round() as u32; (new_width.max(1), new_height.max(1)) } /// Encode a DynamicImage as JPEG with specified quality. fn encode_jpeg(img: &DynamicImage, quality: u8) -> Result, ThumbnailError> { let rgb = img.to_rgb8(); let mut output = Vec::new(); let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); encoder .encode_image(&rgb) .map_err(|e| ThumbnailError::EncodeError(e.to_string()))?; Ok(output) } #[cfg(test)] mod tests { use super::*; #[test] fn test_calculate_dimensions_smaller_than_max() { let (w, h) = calculate_thumbnail_dimensions(100, 80); assert_eq!((w, h), (100, 80)); } #[test] fn test_calculate_dimensions_landscape() { let (w, h) = calculate_thumbnail_dimensions(800, 600); // 800x600 aspect ratio = 4:3 // Max width 200, height would be 150 assert_eq!((w, h), (200, 150)); } #[test] fn test_calculate_dimensions_portrait() { let (w, h) = calculate_thumbnail_dimensions(600, 800); // 600x800 aspect ratio = 3:4 // Max height 150, width would be 112 assert_eq!((w, h), (112, 150)); } #[test] fn test_unsupported_mime_type() { let result = ThumbnailService::generate(b"not an image", Some("application/pdf")).unwrap(); assert!(result.is_none()); } } ================================================ FILE: crates/remote/src/audit/mod.rs ================================================ use uuid::Uuid; use crate::auth::RequestContext; #[derive(Debug, Clone, Copy)] pub enum AuditAction { AuthLogin, AuthLogout, AuthTokenRefresh, AuthTokenReuseDetected, AuthSessionRevoked, MemberInvite, MemberAcceptInvite, MemberRevokeInvite, MemberRemove, MemberRoleChange, } impl AuditAction { pub fn as_str(&self) -> &'static str { match self { Self::AuthLogin => "auth.login", Self::AuthLogout => "auth.logout", Self::AuthTokenRefresh => "auth.token_refresh", Self::AuthTokenReuseDetected => "auth.token_reuse_detected", Self::AuthSessionRevoked => "auth.session_revoked", Self::MemberInvite => "member.invite", Self::MemberAcceptInvite => "member.accept_invite", Self::MemberRevokeInvite => "member.revoke_invite", Self::MemberRemove => "member.remove", Self::MemberRoleChange => "member.role_change", } } } /// A single audit log event. #[derive(Debug, Clone)] pub struct AuditEvent { pub action: AuditAction, pub user_id: Option, pub session_id: Option, pub resource_type: Option<&'static str>, pub resource_id: Option, pub organization_id: Option, pub http_method: Option, pub http_path: Option, pub http_status: Option, pub description: Option, } impl AuditEvent { /// Create an event populated from a request context (user, session). pub fn from_request(ctx: &RequestContext, action: AuditAction) -> Self { Self { action, user_id: Some(ctx.user.id), session_id: Some(ctx.session_id), resource_type: None, resource_id: None, organization_id: None, http_method: None, http_path: None, http_status: None, description: None, } } /// Create a system-level event with no request context. pub fn system(action: AuditAction) -> Self { Self { action, user_id: None, session_id: None, resource_type: None, resource_id: None, organization_id: None, http_method: None, http_path: None, http_status: None, description: None, } } pub fn resource(mut self, resource_type: &'static str, resource_id: Option) -> Self { self.resource_type = Some(resource_type); self.resource_id = resource_id; self } pub fn organization(mut self, id: Uuid) -> Self { self.organization_id = Some(id); self } pub fn http(mut self, method: &str, path: impl Into, status: u16) -> Self { self.http_method = Some(method.into()); self.http_path = Some(path.into()); self.http_status = Some(status); self } pub fn description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); self } pub fn user(mut self, user_id: Uuid, session_id: Option) -> Self { self.user_id = Some(user_id); self.session_id = session_id; self } } /// Emit an audit event as a structured tracing log. /// Uses `target: "audit"` for filtering in the backend. pub fn emit(event: AuditEvent) { tracing::info!( target: "audit", audit_action = event.action.as_str(), audit_user_id = event.user_id.map(|u| u.to_string()).unwrap_or_default(), audit_session_id = event.session_id.map(|s| s.to_string()).unwrap_or_default(), audit_resource_type = event.resource_type.unwrap_or(""), audit_resource_id = event.resource_id.map(|r| r.to_string()).unwrap_or_default(), audit_organization_id = event.organization_id.map(|o| o.to_string()).unwrap_or_default(), audit_http_method = event.http_method.as_deref().unwrap_or(""), audit_http_path = event.http_path.as_deref().unwrap_or(""), audit_http_status = event.http_status.unwrap_or(0), audit_description = event.description.as_deref().unwrap_or(""), "audit_event" ); } ================================================ FILE: crates/remote/src/auth/handoff.rs ================================================ use std::{fmt::Write, sync::Arc}; use anyhow::Error as AnyhowError; use chrono::{DateTime, Duration, Utc}; use rand::{Rng, distr::Alphanumeric}; use reqwest::StatusCode; use secrecy::ExposeSecret; use sha2::{Digest, Sha256}; use sqlx::PgPool; use thiserror::Error; use url::Url; use uuid::Uuid; use super::{ ProviderRegistry, jwt::{JwtError, JwtService}, provider::{AuthorizationGrant, AuthorizationProvider, ProviderUser}, }; use crate::{ configure_user_scope, db::{ auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION}, identity_errors::IdentityError, oauth::{ AuthorizationStatus, CreateOAuthHandoff, OAuthHandoff, OAuthHandoffError, OAuthHandoffRepository, }, oauth_accounts::{OAuthAccountError, OAuthAccountInsert, OAuthAccountRepository}, organizations::OrganizationRepository, users::{UpsertUser, UserRepository}, }, }; const STATE_LENGTH: usize = 48; const APP_CODE_LENGTH: usize = 48; const HANDOFF_TTL: i64 = 10; // minutes const USER_FETCH_MAX_ATTEMPTS: usize = 5; const USER_FETCH_RETRY_DELAY_MS: u64 = 500; #[derive(Debug, Error)] pub enum HandoffError { #[error("unsupported provider `{0}`")] UnsupportedProvider(String), #[error("invalid return url `{0}`")] InvalidReturnUrl(String), #[error("invalid app verifier challenge")] InvalidChallenge, #[error("oauth handoff not found")] NotFound, #[error("oauth handoff expired")] Expired, #[error("oauth authorization denied")] Denied, #[error("oauth authorization failed: {0}")] Failed(String), #[error(transparent)] Provider(#[from] AnyhowError), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] OAuthAccount(#[from] OAuthAccountError), #[error(transparent)] Session(#[from] AuthSessionError), #[error(transparent)] Jwt(#[from] JwtError), #[error(transparent)] Authorization(#[from] OAuthHandoffError), } #[derive(Debug, Clone)] pub struct HandoffInitResponse { pub handoff_id: Uuid, pub authorize_url: String, pub expires_at: DateTime, } #[derive(Debug, Clone)] pub enum CallbackResult { Success { handoff_id: Uuid, return_to: String, app_code: String, }, Error { handoff_id: Option, return_to: Option, error: String, }, } #[derive(Debug, Clone)] pub struct RedeemResponse { pub access_token: String, pub refresh_token: String, pub user_id: Uuid, pub email: String, } pub struct OAuthHandoffService { pool: PgPool, providers: Arc, jwt: Arc, public_origin: String, } impl OAuthHandoffService { pub fn new( pool: PgPool, providers: Arc, jwt: Arc, public_origin: String, ) -> Self { let trimmed_origin = public_origin.trim_end_matches('/').to_string(); Self { pool, providers, jwt, public_origin: trimmed_origin, } } pub fn providers(&self) -> Arc { Arc::clone(&self.providers) } pub async fn initiate( &self, provider: &str, return_to: &str, app_challenge: &str, ) -> Result { let provider = self .providers .get(provider) .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?; let return_to_url = Url::parse(return_to).map_err(|_| HandoffError::InvalidReturnUrl(return_to.into()))?; if !is_allowed_return_to(&return_to_url, &self.public_origin) { return Err(HandoffError::InvalidReturnUrl(return_to.into())); } if !is_valid_challenge(app_challenge) { return Err(HandoffError::InvalidChallenge); } let state = generate_state(); let expires_at = Utc::now() + Duration::minutes(HANDOFF_TTL); let repo = OAuthHandoffRepository::new(&self.pool); let record = repo .create(CreateOAuthHandoff { provider: provider.name(), state: &state, return_to: return_to_url.as_str(), app_challenge, expires_at, }) .await?; let authorize_url = format!( "{}/v1/oauth/{}/start?handoff_id={}", self.public_origin, provider.name(), record.id ); Ok(HandoffInitResponse { handoff_id: record.id, authorize_url, expires_at: record.expires_at, }) } pub async fn authorize_url( &self, provider: &str, handoff_id: Uuid, ) -> Result { let provider = self .providers .get(provider) .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?; let repo = OAuthHandoffRepository::new(&self.pool); let record = repo.get(handoff_id).await?; if record.provider != provider.name() { return Err(HandoffError::UnsupportedProvider(record.provider)); } if is_expired(&record) { repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) .await?; return Err(HandoffError::Expired); } if record.status() != Some(AuthorizationStatus::Pending) { return Err(HandoffError::Failed("invalid_state".into())); } let redirect_uri = format!( "{}/v1/oauth/{}/callback", self.public_origin, provider.name() ); provider .authorize_url(&record.state, &redirect_uri) .map(|url| url.into()) .map_err(HandoffError::Provider) } pub async fn handle_callback( &self, provider_name: &str, state: Option<&str>, code: Option<&str>, error: Option<&str>, ) -> Result { let provider = self .providers .get(provider_name) .ok_or_else(|| HandoffError::UnsupportedProvider(provider_name.to_string()))?; let Some(state_value) = state else { return Ok(CallbackResult::Error { handoff_id: None, return_to: None, error: "missing_state".into(), }); }; let repo = OAuthHandoffRepository::new(&self.pool); let record = repo.get_by_state(state_value).await?; if record.provider != provider.name() { return Err(HandoffError::UnsupportedProvider(record.provider)); } if is_expired(&record) { repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) .await?; return Err(HandoffError::Expired); } if let Some(err_code) = error { repo.set_status(record.id, AuthorizationStatus::Error, Some(err_code)) .await?; return Ok(CallbackResult::Error { handoff_id: Some(record.id), return_to: Some(record.return_to.clone()), error: err_code.to_string(), }); } let code = code.ok_or_else(|| HandoffError::Failed("missing_code".into()))?; let redirect_uri = format!( "{}/v1/oauth/{}/callback", self.public_origin, provider.name() ); let grant = provider .exchange_code(code, &redirect_uri) .await .map_err(HandoffError::Provider)?; let provider_token_details = crate::auth::ProviderTokenDetails { provider: provider.name().to_string(), access_token: grant.access_token.expose_secret().to_string(), refresh_token: grant .refresh_token .as_ref() .map(|t| t.expose_secret().to_string()), expires_at: grant.expires_in.map(|d| (Utc::now() + d).timestamp()), }; let encrypted_tokens = self .jwt .encrypt_provider_tokens(&provider_token_details) .map_err(|e| HandoffError::Failed(format!("Failed to encrypt provider token: {e}")))?; let user_profile = self.fetch_user_with_retries(&provider, &grant).await?; let user = self .upsert_identity(&provider, &user_profile, Some(encrypted_tokens.as_str())) .await?; let session_repo = AuthSessionRepository::new(&self.pool); let session_record = session_repo.create(user.id, None).await?; let app_code = generate_app_code(); let app_code_hash = hash_sha256_hex(&app_code); repo.mark_authorized( record.id, user.id, session_record.id, &app_code_hash, Some(encrypted_tokens), ) .await?; configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); Ok(CallbackResult::Success { handoff_id: record.id, return_to: record.return_to, app_code, }) } pub async fn redeem( &self, handoff_id: Uuid, app_code: &str, app_verifier: &str, ) -> Result { let repo = OAuthHandoffRepository::new(&self.pool); repo.ensure_redeemable(handoff_id).await?; let record = repo.get(handoff_id).await?; if is_expired(&record) { repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) .await?; return Err(HandoffError::Expired); } let expected_code_hash = record .app_code_hash .ok_or_else(|| HandoffError::Failed("missing_app_code".into()))?; let provided_hash = hash_sha256_hex(app_code); if provided_hash != expected_code_hash { return Err(HandoffError::Failed("invalid_app_code".into())); } let expected_challenge = record.app_challenge; let provided_challenge = hash_sha256_hex(app_verifier); if provided_challenge != expected_challenge { return Err(HandoffError::Failed("invalid_app_verifier".into())); } let session_id = record .session_id .ok_or_else(|| HandoffError::Failed("missing_session".into()))?; let user_id = record .user_id .ok_or_else(|| HandoffError::Failed("missing_user".into()))?; let provider = record.provider.clone(); let session_repo = AuthSessionRepository::new(&self.pool); let session = session_repo.get(session_id).await?; if session.revoked_at.is_some() { return Err(HandoffError::Denied); } if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION { session_repo.revoke(session.id).await?; return Err(HandoffError::Denied); } let user_repo = UserRepository::new(&self.pool); let user = user_repo.fetch_user(user_id).await?; let org_repo = OrganizationRepository::new(&self.pool); let _organization = org_repo .ensure_personal_org_and_admin_membership(user.id, user.username.as_deref()) .await?; let tokens = self.jwt.generate_tokens(&session, &user, &provider)?; session_repo .set_current_refresh_token(session.id, tokens.refresh_token_id) .await?; session_repo.touch(session.id).await?; repo.mark_redeemed(record.id).await?; configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); Ok(RedeemResponse { access_token: tokens.access_token, refresh_token: tokens.refresh_token, user_id: user.id, email: user.email, }) } async fn fetch_user_with_retries( &self, provider: &Arc, grant: &AuthorizationGrant, ) -> Result { let mut last_error: Option = None; for attempt in 1..=USER_FETCH_MAX_ATTEMPTS { match provider.fetch_user(&grant.access_token).await { Ok(user) => return Ok(user), Err(err) => { let retryable = attempt < USER_FETCH_MAX_ATTEMPTS && is_forbidden_error(&err); last_error = Some(err); if retryable { tokio::time::sleep(std::time::Duration::from_millis( USER_FETCH_RETRY_DELAY_MS, )) .await; continue; } break; } } } if let Some(err) = last_error { Err(HandoffError::Provider(err)) } else { Err(HandoffError::Failed("user_fetch_failed".into())) } } async fn upsert_identity( &self, provider: &Arc, profile: &ProviderUser, encrypted_provider_tokens: Option<&str>, ) -> Result { let account_repo = OAuthAccountRepository::new(&self.pool); let user_repo = UserRepository::new(&self.pool); let org_repo = OrganizationRepository::new(&self.pool); let email = ensure_email(provider.name(), profile); let username = derive_username(provider.name(), profile); let display_name = derive_display_name(profile); let existing_account = account_repo .get_by_provider_user(provider.name(), &profile.id) .await?; let user_id = match existing_account { Some(account) => account.user_id, None => Uuid::new_v4(), }; let (first_name, last_name) = split_name(profile.name.as_deref()); let user = user_repo .upsert_user(UpsertUser { id: user_id, email: &email, first_name: first_name.as_deref(), last_name: last_name.as_deref(), username: username.as_deref(), }) .await?; org_repo .ensure_personal_org_and_admin_membership(user.id, username.as_deref()) .await?; account_repo .upsert(OAuthAccountInsert { user_id: user.id, provider: provider.name(), provider_user_id: &profile.id, email: Some(email.as_str()), username: username.as_deref(), display_name: display_name.as_deref(), avatar_url: profile.avatar_url.as_deref(), encrypted_provider_tokens, }) .await?; Ok(user) } } type IdentityUser = api_types::User; fn is_expired(record: &OAuthHandoff) -> bool { record.expires_at <= Utc::now() } fn is_valid_challenge(challenge: &str) -> bool { !challenge.is_empty() && challenge.len() == 64 && challenge.chars().all(|ch| ch.is_ascii_hexdigit()) } fn is_allowed_return_to(url: &Url, public_origin: &str) -> bool { if url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) { return true; } if url.scheme() == "https" && Url::parse(public_origin).ok().is_some_and(|public_url| { public_url.scheme() == "https" && public_url.host_str().is_some() && url.host_str() == public_url.host_str() }) { return true; } // Log and allow web-hosted clients. Rely on PKCE for security. tracing::info!(%url, "allowing external redirect URL"); true } fn hash_sha256_hex(input: &str) -> String { let digest = Sha256::digest(input.as_bytes()); let mut output = String::with_capacity(digest.len() * 2); for byte in digest { let _ = write!(output, "{byte:02x}"); } output } fn generate_state() -> String { rand::rng() .sample_iter(&Alphanumeric) .take(STATE_LENGTH) .map(char::from) .collect() } fn generate_app_code() -> String { rand::rng() .sample_iter(&Alphanumeric) .take(APP_CODE_LENGTH) .map(char::from) .collect() } fn ensure_email(provider: &str, profile: &ProviderUser) -> String { if let Some(email) = profile.email.clone() { return email; } match provider { "github" => format!("{}@users.noreply.github.com", profile.id), "google" => format!("{}@users.noreply.google.com", profile.id), _ => format!("{}@oauth.local", profile.id), } } fn derive_username(provider: &str, profile: &ProviderUser) -> Option { if let Some(login) = profile.login.clone() { return Some(login); } if let Some(email) = profile.email.as_deref() { return email.split('@').next().map(|part| part.to_owned()); } Some(format!("{}-{}", provider, profile.id)) } fn derive_display_name(profile: &ProviderUser) -> Option { profile.name.clone() } fn split_name(name: Option<&str>) -> (Option, Option) { match name { Some(value) => { let mut iter = value.split_whitespace(); let first = iter.next().map(|s| s.to_string()); let remainder: Vec<&str> = iter.collect(); let last = if remainder.is_empty() { None } else { Some(remainder.join(" ")) }; (first, last) } None => (None, None), } } fn is_forbidden_error(err: &AnyhowError) -> bool { err.chain().any(|cause| { cause .downcast_ref::() .and_then(|req_err| req_err.status()) .map(|status| status == StatusCode::FORBIDDEN) .unwrap_or(false) }) } #[cfg(test)] mod tests { use super::*; #[test] fn hashes_match_hex_length() { let output = hash_sha256_hex("example"); assert_eq!(output.len(), 64); } #[test] fn challenge_validation() { assert!(is_valid_challenge( "0d44b13d0112ff7c94f27f66a701d89f5cb9184160a95cace0bbd10b191ed257" )); assert!(!is_valid_challenge("not-hex")); assert!(!is_valid_challenge("")); } } ================================================ FILE: crates/remote/src/auth/jwt.rs ================================================ use std::{collections::HashSet, sync::Arc}; use aes_gcm::{ Aes256Gcm, Key, Nonce, aead::{Aead, AeadCore, KeyInit, OsRng}, }; use api_types::User; use base64::{ Engine as _, engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, }; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; use uuid::Uuid; use crate::{auth::provider::ProviderTokenDetails, db::auth::AuthSession}; pub const ACCESS_TOKEN_TTL_SECONDS: i64 = 120; pub const REFRESH_TOKEN_TTL_DAYS: i64 = 365; const DEFAULT_JWT_LEEWAY_SECONDS: u64 = 60; #[derive(Debug, Error)] pub enum JwtError { #[error("invalid token")] InvalidToken, #[error("invalid jwt secret")] InvalidSecret, #[error("token expired")] TokenExpired, #[error("refresh token reused - possible theft detected")] TokenReuseDetected, #[error("session revoked")] SessionRevoked, #[error("token type mismatch")] InvalidTokenType, #[error("encryption error")] EncryptionError, #[error("serialization error")] SerializationError, #[error(transparent)] Jwt(#[from] jsonwebtoken::errors::Error), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccessTokenClaims { pub sub: Uuid, pub session_id: Uuid, pub iat: i64, pub exp: i64, pub aud: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefreshTokenClaims { pub sub: Uuid, pub session_id: Uuid, pub jti: Uuid, pub iat: i64, pub exp: i64, pub aud: String, pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_tokens_blob: Option, // Legacy claim for older refresh tokens } #[derive(Debug, Clone)] pub struct AccessTokenDetails { pub user_id: Uuid, pub session_id: Uuid, pub expires_at: DateTime, } #[derive(Debug, Clone)] pub struct RefreshTokenDetails { pub user_id: Uuid, pub session_id: Uuid, pub refresh_token_id: Uuid, pub provider: String, pub legacy_provider_token_details: Option, } #[derive(Clone)] pub struct JwtService { pub secret: Arc, } #[derive(Debug, Clone)] pub struct Tokens { pub access_token: String, pub refresh_token: String, pub refresh_token_id: Uuid, } impl JwtService { pub fn new(secret: SecretString) -> Self { Self { secret: Arc::new(secret), } } pub fn generate_tokens( &self, session: &AuthSession, user: &User, provider: &str, ) -> Result { let now = Utc::now(); let refresh_token_id = Uuid::new_v4(); // Access token, short-lived (~2 minutes) let access_exp = now + ChronoDuration::seconds(ACCESS_TOKEN_TTL_SECONDS); let access_claims = AccessTokenClaims { sub: user.id, session_id: session.id, iat: now.timestamp(), exp: access_exp.timestamp(), aud: "access".to_string(), }; // Refresh token, long-lived (~1 year) let refresh_exp = now + ChronoDuration::days(REFRESH_TOKEN_TTL_DAYS); let refresh_claims = RefreshTokenClaims { sub: user.id, session_id: session.id, jti: refresh_token_id, iat: now.timestamp(), exp: refresh_exp.timestamp(), aud: "refresh".to_string(), provider: Some(provider.to_string()), provider_tokens_blob: None, }; let encoding_key = EncodingKey::from_base64_secret(self.secret.expose_secret())?; let access_token = encode( &Header::new(Algorithm::HS256), &access_claims, &encoding_key, )?; let refresh_token = encode( &Header::new(Algorithm::HS256), &refresh_claims, &encoding_key, )?; Ok(Tokens { access_token, refresh_token, refresh_token_id, }) } pub fn generate_access_token( &self, user_id: Uuid, session_id: Uuid, ) -> Result { let now = Utc::now(); let access_exp = now + ChronoDuration::seconds(ACCESS_TOKEN_TTL_SECONDS); let claims = AccessTokenClaims { sub: user_id, session_id, iat: now.timestamp(), exp: access_exp.timestamp(), aud: "access".to_string(), }; let encoding_key = EncodingKey::from_base64_secret(self.secret.expose_secret())?; Ok(encode( &Header::new(Algorithm::HS256), &claims, &encoding_key, )?) } pub fn decode_access_token(&self, token: &str) -> Result { self.decode_access_token_with_leeway(token, DEFAULT_JWT_LEEWAY_SECONDS) } pub fn decode_access_token_with_leeway( &self, token: &str, leeway_seconds: u64, ) -> Result { if token.trim().is_empty() { return Err(JwtError::InvalidToken); } let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = true; validation.validate_nbf = false; validation.set_audience(&["access"]); validation.required_spec_claims = HashSet::from(["sub".to_string(), "exp".to_string(), "aud".to_string()]); validation.leeway = leeway_seconds; let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?; let data = decode::(token, &decoding_key, &validation)?; let claims = data.claims; let expires_at = DateTime::from_timestamp(claims.exp, 0).ok_or(JwtError::InvalidToken)?; Ok(AccessTokenDetails { user_id: claims.sub, session_id: claims.session_id, expires_at, }) } pub fn decode_refresh_token(&self, token: &str) -> Result { if token.trim().is_empty() { return Err(JwtError::InvalidToken); } let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = true; validation.validate_nbf = false; validation.set_audience(&["refresh"]); validation.required_spec_claims = HashSet::from([ "sub".to_string(), "exp".to_string(), "aud".to_string(), "jti".to_string(), ]); validation.leeway = DEFAULT_JWT_LEEWAY_SECONDS; let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?; let data = decode::(token, &decoding_key, &validation)?; let claims = data.claims; let (provider, legacy_provider_token_details) = if let Some(provider) = claims.provider.as_ref().filter(|p| !p.trim().is_empty()) { (provider.to_string(), None) } else if let Some(provider_tokens_blob) = claims.provider_tokens_blob.as_deref() { let provider_token_details = self.decrypt_provider_tokens(provider_tokens_blob)?; ( provider_token_details.provider.clone(), Some(provider_token_details), ) } else { return Err(JwtError::InvalidToken); }; Ok(RefreshTokenDetails { user_id: claims.sub, session_id: claims.session_id, refresh_token_id: claims.jti, provider, legacy_provider_token_details, }) } pub fn decrypt_provider_tokens( &self, provider_tokens_blob: &str, ) -> Result { let decrypted = self.decrypt_data(provider_tokens_blob)?; let decrypted_str = String::from_utf8_lossy(&decrypted); serde_json::from_str(&decrypted_str).map_err(|_| JwtError::InvalidToken) } pub fn encrypt_provider_tokens( &self, provider_tokens: &ProviderTokenDetails, ) -> Result { let json = serde_json::to_string(provider_tokens).map_err(|_| JwtError::SerializationError)?; self.encrypt_data(json.as_bytes()) } fn encrypt_data(&self, data: &[u8]) -> Result { let key_bytes = self.derive_key()?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, data) .map_err(|_| JwtError::EncryptionError)?; let mut combined = nonce.to_vec(); combined.extend_from_slice(&ciphertext); Ok(URL_SAFE_NO_PAD.encode(combined)) } fn decrypt_data(&self, encrypted: &str) -> Result, JwtError> { let decoded = URL_SAFE_NO_PAD .decode(encrypted) .map_err(|_| JwtError::InvalidToken)?; const NONCE_SIZE: usize = 12; // 96 bits for AES-256-GCM if decoded.len() < NONCE_SIZE { return Err(JwtError::InvalidToken); } let key_bytes = self.derive_key()?; let key = Key::::from(key_bytes); let cipher = Aes256Gcm::new(&key); let nonce_bytes: [u8; NONCE_SIZE] = decoded[..NONCE_SIZE] .try_into() .map_err(|_| JwtError::InvalidToken)?; let nonce = Nonce::from(nonce_bytes); let ciphertext = &decoded[NONCE_SIZE..]; cipher .decrypt(&nonce, ciphertext) .map_err(|_| JwtError::EncryptionError) } fn derive_key(&self) -> Result<[u8; 32], JwtError> { let secret_bytes = STANDARD .decode(self.secret.expose_secret()) .map_err(|_| JwtError::InvalidSecret)?; let mut hasher = Sha256::new(); hasher.update(&secret_bytes); Ok(hasher.finalize().into()) } } ================================================ FILE: crates/remote/src/auth/middleware.rs ================================================ use api_types::User; use axum::{ body::Body, extract::State, http::{Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer}; use chrono::{DateTime, Utc}; use tower_http::request_id::RequestId; use tracing::{Span, warn}; use uuid::Uuid; use crate::{ AppState, audit, audit::{AuditAction, AuditEvent}, configure_user_scope, db::{ self, auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION}, identity_errors::IdentityError, users::UserRepository, }, }; #[derive(Clone)] pub struct RequestContext { pub user: User, pub session_id: Uuid, #[allow(dead_code)] pub access_token_expires_at: DateTime, } pub async fn require_session( State(state): State, mut req: Request, next: Next, ) -> Response { let bearer = match req.headers().typed_get::>() { Some(Authorization(token)) => token.token().to_owned(), None => return StatusCode::UNAUTHORIZED.into_response(), }; let ctx = match request_context_from_access_token(&state, &bearer).await { Ok(ctx) => ctx, Err(response) => return response, }; Span::current().record("user_id", tracing::field::display(ctx.user.id)); let request_id = req .extensions() .get::() .and_then(|id| id.header_value().to_str().ok()) .unwrap_or("") .to_owned(); let tx_ctx = db::TxContext { user_id: ctx.user.id, request_id, }; req.extensions_mut().insert(ctx); db::TX_CONTEXT.scope(Some(tx_ctx), next.run(req)).await } pub async fn request_context_from_access_token( state: &AppState, access_token: &str, ) -> Result { let jwt = state.jwt(); let identity = match jwt.decode_access_token(access_token) { Ok(details) => details, Err(error) => { warn!(?error, "failed to decode access token"); return Err(StatusCode::UNAUTHORIZED.into_response()); } }; let mut ctx = request_context_from_auth_session_id(state, identity.session_id).await?; if ctx.user.id != identity.user_id { warn!( token_user_id = %identity.user_id, session_user_id = %ctx.user.id, session_id = %identity.session_id, "access token user does not match session user" ); return Err(StatusCode::UNAUTHORIZED.into_response()); } ctx.access_token_expires_at = identity.expires_at; Ok(ctx) } pub async fn request_context_from_auth_session_id( state: &AppState, session_id: Uuid, ) -> Result { let pool = state.pool(); let session_repo = AuthSessionRepository::new(pool); let session = match session_repo.get(session_id).await { Ok(session) => session, Err(AuthSessionError::NotFound) => { warn!("session `{}` not found", session_id); return Err(StatusCode::UNAUTHORIZED.into_response()); } Err(AuthSessionError::Database(error)) => { warn!(?error, "failed to load session"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } Err(_) => { warn!("failed to load session for unknown reason"); return Err(StatusCode::UNAUTHORIZED.into_response()); } }; if session.revoked_at.is_some() { warn!("session `{}` rejected (revoked)", session.id); return Err(StatusCode::UNAUTHORIZED.into_response()); } if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION { warn!( "session `{}` expired due to inactivity; revoking", session.id ); if let Err(error) = session_repo.revoke(session.id).await { warn!(?error, "failed to revoke inactive session"); } audit::emit( AuditEvent::system(AuditAction::AuthSessionRevoked) .user(session.user_id, Some(session.id)) .resource("auth_session", Some(session.id)) .http("", "", 401) .description("Session revoked due to inactivity"), ); return Err(StatusCode::UNAUTHORIZED.into_response()); } let user_repo = UserRepository::new(pool); let user = match user_repo.fetch_user(session.user_id).await { Ok(user) => user, Err(IdentityError::NotFound) => { warn!("user `{}` missing", session.user_id); return Err(StatusCode::UNAUTHORIZED.into_response()); } Err(IdentityError::Database(error)) => { warn!(?error, "failed to load user"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } Err(_) => { warn!("unexpected error loading user"); return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()); } }; configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); let ctx = RequestContext { user, session_id: session.id, access_token_expires_at: Utc::now(), }; match session_repo.touch(session.id).await { Ok(_) => {} Err(error) => warn!(?error, "failed to update session last-used timestamp"), } Ok(ctx) } ================================================ FILE: crates/remote/src/auth/mod.rs ================================================ mod handoff; mod jwt; mod middleware; mod oauth_token_validator; mod provider; pub use handoff::{CallbackResult, HandoffError, OAuthHandoffService}; pub use jwt::{JwtError, JwtService}; pub use middleware::{RequestContext, require_session}; pub use oauth_token_validator::{OAuthTokenValidationError, OAuthTokenValidator}; pub use provider::{ GitHubOAuthProvider, GoogleOAuthProvider, ProviderRegistry, ProviderTokenDetails, }; ================================================ FILE: crates/remote/src/auth/oauth_token_validator.rs ================================================ use std::sync::Arc; use sqlx::PgPool; use tracing::{error, info, warn}; use uuid::Uuid; use crate::{ audit::{self, AuditAction, AuditEvent}, auth::{ JwtService, ProviderTokenDetails, provider::{ProviderRegistry, TokenValidationError, VALIDATE_TOKEN_MAX_RETRIES}, }, db::{ auth::AuthSessionRepository, oauth_accounts::{OAuthAccountError, OAuthAccountRepository}, }, }; #[derive(Debug, thiserror::Error)] pub enum OAuthTokenValidationError { #[error("failed to fetch OAuth accounts for user")] FetchAccountsFailed(OAuthAccountError), #[error("provider account no longer linked to user")] ProviderAccountNotLinked, #[error("OAuth provider token validation failed")] ProviderTokenValidationFailed, #[error("temporary failure validating provider token: {0}")] ValidationUnavailable(String), } pub struct OAuthTokenValidator { pool: PgPool, provider_registry: Arc, jwt: Arc, } impl OAuthTokenValidator { pub fn new( pool: PgPool, provider_registry: Arc, jwt: Arc, ) -> Self { Self { pool, provider_registry, jwt, } } // Check if the OAuth provider token is still valid, refresh if possible // Revoke all sessions if provider has revoked the OAuth token pub async fn validate( &self, provider: &str, user_id: Uuid, session_id: Uuid, ) -> Result<(), OAuthTokenValidationError> { match self.verify_inner(provider, user_id, session_id).await { Ok(()) => Ok(()), Err(err) => { match &err { OAuthTokenValidationError::ProviderAccountNotLinked | OAuthTokenValidationError::ProviderTokenValidationFailed | OAuthTokenValidationError::FetchAccountsFailed(_) => { let session_repo = AuthSessionRepository::new(&self.pool); if let Err(e) = session_repo.revoke_all_user_sessions(user_id).await { error!( user_id = %user_id, error = %e, "Failed to revoke all user sessions after OAuth token validation failure" ); } audit::emit( AuditEvent::system(AuditAction::AuthSessionRevoked) .user(user_id, Some(session_id)) .resource("auth_session", None) .description("All sessions revoked: OAuth provider token invalid"), ); } OAuthTokenValidationError::ValidationUnavailable(_) => (), }; Err(err) } } } async fn verify_inner( &self, provider_name: &str, user_id: Uuid, session_id: Uuid, ) -> Result<(), OAuthTokenValidationError> { let oauth_account_repo = OAuthAccountRepository::new(&self.pool); let account = match oauth_account_repo .get_by_user_provider(user_id, provider_name) .await { Ok(account) => account, Err(err) => { error!( user_id = %user_id, error = %err, provider = %provider_name, "Failed to fetch OAuth account for user" ); return Err(OAuthTokenValidationError::FetchAccountsFailed(err)); } }; let Some(account) = account else { warn!( user_id = %user_id, provider = %provider_name, "Provider account no longer linked to user, revoking sessions" ); return Err(OAuthTokenValidationError::ProviderAccountNotLinked); }; let Some(encrypted_tokens) = account.encrypted_provider_tokens.as_deref() else { error!( user_id = %user_id, provider = %provider_name, session_id = %session_id, "OAuth account is missing provider token" ); return Err(OAuthTokenValidationError::ProviderTokenValidationFailed); }; let mut provider_token_details = match self.jwt.decrypt_provider_tokens(encrypted_tokens) { Ok(details) => details, Err(err) => { error!( user_id = %user_id, provider = %provider_name, session_id = %session_id, error = %err, "Failed to decrypt provider token from oauth account" ); return Err(OAuthTokenValidationError::ProviderTokenValidationFailed); } }; if provider_token_details.provider != provider_name { error!( user_id = %user_id, provider = %provider_name, session_id = %session_id, "Provider token details did not match linked provider account" ); return Err(OAuthTokenValidationError::ProviderTokenValidationFailed); } let Some(provider) = self.provider_registry.get(provider_name) else { error!( user_id = %user_id, provider = %provider_name, "OAuth provider not found in registry, revoking all sessions" ); return Err(OAuthTokenValidationError::ProviderTokenValidationFailed); }; match provider .validate_token(&provider_token_details, VALIDATE_TOKEN_MAX_RETRIES) .await { Ok(Some(updated_token_details)) => { provider_token_details = updated_token_details; self.persist_provider_tokens( &oauth_account_repo, user_id, provider_name, &provider_token_details, ) .await?; } Ok(None) => {} Err(TokenValidationError::InvalidOrRevoked) => { info!( user_id = %user_id, provider = %provider_name, session_id = %session_id, "OAuth provider reported token as invalid or revoked" ); return Err(OAuthTokenValidationError::ProviderTokenValidationFailed); } Err(TokenValidationError::Temporary(reason)) => { warn!( user_id = %user_id, provider = %provider_name, session_id = %session_id, error = %reason, "OAuth provider validation temporarily unavailable" ); return Err(OAuthTokenValidationError::ValidationUnavailable(reason)); } } Ok(()) } async fn persist_provider_tokens( &self, oauth_account_repo: &OAuthAccountRepository<'_>, user_id: Uuid, provider: &str, provider_token_details: &ProviderTokenDetails, ) -> Result<(), OAuthTokenValidationError> { let encrypted_provider_tokens = self .jwt .encrypt_provider_tokens(provider_token_details) .map_err(|err| { error!( user_id = %user_id, provider = %provider, error = %err, "Failed to encrypt provider token for persistence" ); OAuthTokenValidationError::ValidationUnavailable( "failed to encrypt provider token".to_string(), ) })?; oauth_account_repo .update_encrypted_provider_tokens(user_id, provider, &encrypted_provider_tokens) .await .map_err(|err| { error!( user_id = %user_id, provider = %provider, error = %err, "Failed to persist provider token on oauth account" ); OAuthTokenValidationError::ValidationUnavailable( "failed to persist provider token".to_string(), ) })?; Ok(()) } } ================================================ FILE: crates/remote/src/auth/provider.rs ================================================ use std::{collections::HashMap, sync::Arc}; use anyhow::{Context, Result}; use async_trait::async_trait; use chrono::Duration; use reqwest::Client; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::info; use url::Url; const USER_AGENT: &str = "VibeKanbanRemote/1.0"; const TOKEN_EXPIRATION_LEEWAY_SECONDS: i64 = 20; pub const VALIDATE_TOKEN_MAX_RETRIES: u32 = 3; const RETRY_INTERVAL_SECONDS: u64 = 2; #[derive(Debug, Clone)] pub struct AuthorizationGrant { pub access_token: SecretString, pub token_type: String, pub scopes: Vec, pub refresh_token: Option, pub expires_in: Option, pub id_token: Option, } #[derive(Debug)] pub struct ProviderUser { pub id: String, pub login: Option, pub email: Option, pub name: Option, pub avatar_url: Option, } #[derive(Debug, Error)] pub enum TokenValidationError { #[error("provider token invalid or revoked")] InvalidOrRevoked, #[error("provider validation temporarily unavailable: {0}")] Temporary(String), } impl TokenValidationError { fn temporary(message: impl Into) -> Self { Self::Temporary(message.into()) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderTokenDetails { pub provider: String, pub access_token: String, pub refresh_token: Option, pub expires_at: Option, } #[async_trait] pub trait AuthorizationProvider: Send + Sync { fn name(&self) -> &'static str; fn scopes(&self) -> &[&str]; fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result; async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result; async fn fetch_user(&self, access_token: &SecretString) -> Result; async fn validate_token( &self, token_details: &ProviderTokenDetails, max_retries: u32, ) -> Result, TokenValidationError>; } #[derive(Default)] pub struct ProviderRegistry { providers: HashMap>, } impl ProviderRegistry { pub fn new() -> Self { Self::default() } pub fn register

(&mut self, provider: P) where P: AuthorizationProvider + 'static, { let key = provider.name().to_lowercase(); self.providers.insert(key, Arc::new(provider)); } pub fn get(&self, provider: &str) -> Option> { let key = provider.to_lowercase(); self.providers.get(&key).cloned() } pub fn is_empty(&self) -> bool { self.providers.is_empty() } } pub struct GitHubOAuthProvider { client: Client, client_id: String, client_secret: SecretString, } impl GitHubOAuthProvider { pub fn new(client_id: String, client_secret: SecretString) -> Result { let client = Client::builder().user_agent(USER_AGENT).build()?; Ok(Self { client, client_id, client_secret, }) } fn parse_scopes(scope: Option) -> Vec { scope .unwrap_or_default() .split(',') .filter_map(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then_some(trimmed.to_string()) }) .collect() } } #[derive(Debug, Deserialize)] #[serde(untagged)] enum GitHubTokenResponse { Success { access_token: String, scope: Option, token_type: String, }, Error { error: String, error_description: Option, }, } #[derive(Debug, Deserialize)] struct GitHubUser { id: i64, login: String, email: Option, name: Option, avatar_url: Option, } #[derive(Debug, Deserialize)] struct GitHubEmail { email: String, primary: bool, verified: bool, } #[async_trait] impl AuthorizationProvider for GitHubOAuthProvider { fn name(&self) -> &'static str { "github" } fn scopes(&self) -> &[&str] { &["read:user", "user:email"] } fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result { let mut url = Url::parse("https://github.com/login/oauth/authorize")?; { let mut qp = url.query_pairs_mut(); qp.append_pair("client_id", &self.client_id); qp.append_pair("state", state); qp.append_pair("redirect_uri", redirect_uri); qp.append_pair("allow_signup", "false"); qp.append_pair("scope", &self.scopes().join(" ")); } Ok(url) } async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result { let response = self .client .post("https://github.com/login/oauth/access_token") .header("Accept", "application/json") .form(&[ ("client_id", self.client_id.as_str()), ("client_secret", self.client_secret.expose_secret()), ("code", code), ("redirect_uri", redirect_uri), ]) .send() .await? .error_for_status()?; match response.json::().await? { GitHubTokenResponse::Success { access_token, scope, token_type, } => Ok(AuthorizationGrant { access_token: SecretString::new(access_token.into()), token_type, scopes: Self::parse_scopes(scope), refresh_token: None, expires_in: None, id_token: None, }), GitHubTokenResponse::Error { error, error_description, } => { let detail = error_description.unwrap_or_else(|| error.clone()); anyhow::bail!("github token exchange failed: {detail}") } } } async fn fetch_user(&self, access_token: &SecretString) -> Result { let bearer = format!("Bearer {}", access_token.expose_secret()); let user: GitHubUser = self .client .get("https://api.github.com/user") .header("Accept", "application/vnd.github+json") .header("Authorization", &bearer) .send() .await? .error_for_status()? .json() .await?; let email = if user.email.is_some() { user.email } else { let response = self .client .get("https://api.github.com/user/emails") .header("Accept", "application/vnd.github+json") .header("Authorization", bearer) .send() .await?; if response.status().is_success() { let emails: Vec = response .json() .await .context("failed to parse GitHub email response")?; emails .into_iter() .find(|entry| entry.primary && entry.verified) .map(|entry| entry.email) } else { None } }; Ok(ProviderUser { id: user.id.to_string(), login: Some(user.login), email, name: user.name, avatar_url: user.avatar_url, }) } async fn validate_token( &self, token_details: &ProviderTokenDetails, max_retries: u32, ) -> Result, TokenValidationError> { let mut attempt = 0; let access_token = SecretString::new(token_details.access_token.clone().into_boxed_str()); loop { attempt += 1; let response = match self .client .get("https://api.github.com/rate_limit") .header( "Authorization", format!("Bearer {}", access_token.expose_secret()), ) .header("Accept", "application/vnd.github+json") .send() .await { Ok(resp) => resp, Err(err) => { if attempt >= max_retries { return Err(TokenValidationError::temporary(format!( "request failed: {err}" ))); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; continue; } }; match response.status() { reqwest::StatusCode::OK => { // GitHub tokens don't expire return Ok(None); } reqwest::StatusCode::UNAUTHORIZED => { return Err(TokenValidationError::InvalidOrRevoked); } reqwest::StatusCode::FORBIDDEN => { // Check if rate limited let rate_limit_remaining = response .headers() .get("x-ratelimit-remaining") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .unwrap_or(1); if rate_limit_remaining == 0 { if attempt <= max_retries { // Get reset time and wait if let Some(reset_str) = response .headers() .get("x-ratelimit-reset") .and_then(|v| v.to_str().ok()) && let Ok(reset_time) = reset_str.parse::() { let now = chrono::Utc::now().timestamp(); let wait_seconds = (reset_time - now).clamp(0, 5); tokio::time::sleep(tokio::time::Duration::from_secs( wait_seconds as u64, )) .await; continue; } } return Err(TokenValidationError::temporary("rate limited by GitHub")); } else { return Err(TokenValidationError::temporary( "access forbidden during validation", )); } } status => { if status.is_server_error() && attempt <= max_retries { tokio::time::sleep(tokio::time::Duration::from_secs( RETRY_INTERVAL_SECONDS, )) .await; continue; } return Err(TokenValidationError::temporary(format!( "unexpected validation status: {status}" ))); } } } } } pub struct GoogleOAuthProvider { client: Client, client_id: String, client_secret: SecretString, } impl GoogleOAuthProvider { pub fn new(client_id: String, client_secret: SecretString) -> Result { let client = Client::builder().user_agent(USER_AGENT).build()?; Ok(Self { client, client_id, client_secret, }) } async fn try_refresh_access_token( &self, refresh_token: &str, ) -> Result { let response = match self .client .post("https://oauth2.googleapis.com/token") .form(&[ ("client_id", self.client_id.as_str()), ("client_secret", self.client_secret.expose_secret()), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ]) .send() .await { Ok(resp) => resp, Err(err) => { return Err(TokenValidationError::temporary(format!( "refresh request failed: {err}" ))); } }; match response.status() { reqwest::StatusCode::OK => { #[derive(Debug, Deserialize)] struct RefreshResponse { access_token: String, expires_in: i64, #[serde(default)] refresh_token: Option, } let refresh_data: RefreshResponse = response .json() .await .map_err(|err| TokenValidationError::temporary(format!("{err}")))?; let expires_at = chrono::Utc::now().timestamp() + refresh_data.expires_in; let new_refresh_token = refresh_data .refresh_token .unwrap_or_else(|| refresh_token.to_string()); Ok(ProviderTokenDetails { provider: self.name().to_string(), access_token: refresh_data.access_token, refresh_token: Some(new_refresh_token), expires_at: Some(expires_at), }) } reqwest::StatusCode::BAD_REQUEST => Err(TokenValidationError::InvalidOrRevoked), status if status.is_server_error() => Err(TokenValidationError::temporary(format!( "token refresh server error: {status}" ))), status => Err(TokenValidationError::temporary(format!( "unexpected token refresh status: {status}" ))), } } async fn refresh_token( &self, refresh_token: &str, max_retries: u32, ) -> Result { let mut attempt = 0; loop { attempt += 1; match self.try_refresh_access_token(refresh_token).await { Ok(new_token_details) => return Ok(new_token_details), Err(TokenValidationError::InvalidOrRevoked) => { return Err(TokenValidationError::InvalidOrRevoked); } Err(TokenValidationError::Temporary(err)) => { if attempt >= max_retries { return Err(TokenValidationError::Temporary(err)); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; } } } } } #[derive(Debug, Deserialize)] #[serde(untagged)] enum GoogleTokenResponse { Success { access_token: String, token_type: String, scope: Option, expires_in: Option, refresh_token: Option, id_token: Option, }, Error { error: String, error_description: Option, }, } #[derive(Debug, Deserialize)] struct GoogleUser { sub: String, email: Option, name: Option, given_name: Option, family_name: Option, picture: Option, } #[async_trait] impl AuthorizationProvider for GoogleOAuthProvider { fn name(&self) -> &'static str { "google" } fn scopes(&self) -> &[&str] { &["openid", "email", "profile"] } fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result { let mut url = Url::parse("https://accounts.google.com/o/oauth2/v2/auth")?; { let mut qp = url.query_pairs_mut(); qp.append_pair("client_id", &self.client_id); qp.append_pair("redirect_uri", redirect_uri); qp.append_pair("response_type", "code"); qp.append_pair("scope", &self.scopes().join(" ")); qp.append_pair("state", state); qp.append_pair("access_type", "offline"); qp.append_pair("prompt", "consent"); } Ok(url) } async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result { let response = self .client .post("https://oauth2.googleapis.com/token") .form(&[ ("client_id", self.client_id.as_str()), ("client_secret", self.client_secret.expose_secret()), ("code", code), ("grant_type", "authorization_code"), ("redirect_uri", redirect_uri), ]) .send() .await? .error_for_status()?; match response.json::().await? { GoogleTokenResponse::Success { access_token, token_type, scope, expires_in, refresh_token, id_token, } => { let scopes = scope .unwrap_or_default() .split_whitespace() .filter_map(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then_some(trimmed.to_string()) }) .collect(); Ok(AuthorizationGrant { access_token: SecretString::new(access_token.into()), token_type, scopes, refresh_token: refresh_token.map(|v| SecretString::new(v.into())), expires_in: expires_in.map(Duration::seconds), id_token: id_token.map(|v| SecretString::new(v.into())), }) } GoogleTokenResponse::Error { error, error_description, } => { let detail = error_description.unwrap_or_else(|| error.clone()); anyhow::bail!("google token exchange failed: {detail}") } } } async fn fetch_user(&self, access_token: &SecretString) -> Result { let bearer = format!("Bearer {}", access_token.expose_secret()); let profile: GoogleUser = self .client .get("https://openidconnect.googleapis.com/v1/userinfo") .header("Authorization", bearer) .send() .await? .error_for_status()? .json() .await?; let login = profile.email.clone(); let name = profile .name .or_else(|| match (profile.given_name, profile.family_name) { (Some(first), Some(last)) => Some(format!("{first} {last}")), (Some(first), None) => Some(first), (None, Some(last)) => Some(last), (None, None) => None, }); Ok(ProviderUser { id: profile.sub, login, email: profile.email, name, avatar_url: profile.picture, }) } async fn validate_token( &self, token_details: &ProviderTokenDetails, max_retries: u32, ) -> Result, TokenValidationError> { let mut attempt = 0; let access_token = SecretString::new(token_details.access_token.clone().into_boxed_str()); loop { attempt += 1; if let Some(expires_at) = token_details.expires_at && let now = chrono::Utc::now().timestamp() && now >= expires_at - TOKEN_EXPIRATION_LEEWAY_SECONDS { let Some(refresh_token) = &token_details.refresh_token else { return Err(TokenValidationError::InvalidOrRevoked); }; info!("Token expired, attempting refresh for Google OAuth"); return self .refresh_token(refresh_token, max_retries) .await .map(Some); } let response = match self .client .get("https://www.googleapis.com/oauth2/v2/tokeninfo") .query(&[("access_token", access_token.expose_secret())]) .send() .await { Ok(resp) => resp, Err(err) => { if attempt >= max_retries { return Err(TokenValidationError::temporary(format!( "tokeninfo request failed: {err}" ))); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; continue; } }; match response.status() { reqwest::StatusCode::OK => { return Ok(None); } reqwest::StatusCode::BAD_REQUEST => { let Some(refresh_token) = &token_details.refresh_token else { return Err(TokenValidationError::InvalidOrRevoked); }; info!("Token expired during validation, attempting refresh"); return self .refresh_token(refresh_token, max_retries) .await .map(Some); } reqwest::StatusCode::TOO_MANY_REQUESTS => { if attempt >= max_retries { return Err(TokenValidationError::temporary( "rate limited by Google".to_string(), )); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; } status if status.is_server_error() => { if attempt >= max_retries { return Err(TokenValidationError::temporary(format!( "google tokeninfo server error: {status}" ))); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; } status => { if attempt >= max_retries { return Err(TokenValidationError::temporary(format!( "unexpected tokeninfo status: {status}" ))); } tokio::time::sleep(tokio::time::Duration::from_secs(RETRY_INTERVAL_SECONDS)) .await; } } } } } ================================================ FILE: crates/remote/src/azure_blob.rs ================================================ use std::{fmt, sync::Arc, time::Duration}; use azure_core::{ credentials::Secret, http::{ClientOptions, RequestContent}, }; use azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId}; use azure_storage_blob::{ BlobClient, BlobContainerClient, BlobServiceClient, BlobServiceClientOptions, models::{BlobClientGetPropertiesResultHeaders, BlockBlobClientUploadOptions}, }; use base64::prelude::*; use chrono::{DateTime, Utc}; use hmac::{Hmac, Mac}; use secrecy::ExposeSecret; use sha2::Sha256; use time::OffsetDateTime; use url::form_urlencoded; use crate::{ config::{AzureAuthMode, AzureBlobConfig}, shared_key_auth::SharedKeyAuthorizationPolicy, }; #[derive(Clone)] pub struct AzureBlobService { service_client: Arc, account_name: String, account_key: String, container_name: String, endpoint_url: Option, public_endpoint_url: Option, presign_expiry: Duration, } #[derive(Debug)] pub struct PresignedUpload { pub upload_url: String, pub blob_path: String, pub expires_at: DateTime, } #[derive(Debug)] pub struct BlobProperties { pub content_length: i64, } #[derive(Debug, thiserror::Error)] pub enum AzureBlobError { #[error("azure storage error: {0}")] Storage(String), #[error("blob not found: {0}")] NotFound(String), #[error("SAS token error: {0}")] SasToken(String), } impl AzureBlobService { pub fn new(config: &AzureBlobConfig) -> Self { let account_name = config.account_name.clone(); let account_key = config.account_key.expose_secret().to_string(); let container_name = config.container_name.clone(); let endpoint_url = config.endpoint_url.clone(); let public_endpoint_url = config.public_endpoint_url.clone(); let presign_expiry = Duration::from_secs(config.presign_expiry_secs); let endpoint = match &endpoint_url { Some(url) => url.clone(), None => format!("https://{}.blob.core.windows.net", account_name), }; let service_client = match &config.auth_mode { AzureAuthMode::EntraId { client_id } => { let credential = ManagedIdentityCredential::new(Some(ManagedIdentityCredentialOptions { user_assigned_id: Some(UserAssignedId::ClientId(client_id.clone())), ..Default::default() })) .expect("failed to create ManagedIdentityCredential"); Arc::new( BlobServiceClient::new(&endpoint, Some(credential), None) .expect("failed to create BlobServiceClient with managed identity"), ) } AzureAuthMode::SharedKey => { let policy = Arc::new(SharedKeyAuthorizationPolicy { account: account_name.clone(), key: Secret::new(account_key.clone()), }); Arc::new( BlobServiceClient::new( &endpoint, None, Some(BlobServiceClientOptions { client_options: ClientOptions { per_try_policies: vec![policy], ..Default::default() }, ..Default::default() }), ) .expect("failed to create BlobServiceClient with shared key"), ) } }; Self { service_client, account_name, account_key, container_name, endpoint_url, public_endpoint_url, presign_expiry, } } fn container_client(&self) -> BlobContainerClient { self.service_client .blob_container_client(&self.container_name) } fn blob_client(&self, blob_path: &str) -> BlobClient { self.container_client().blob_client(blob_path) } pub fn create_upload_url(&self, blob_path: &str) -> Result { let expiry_chrono = Utc::now() + chrono::Duration::from_std(self.presign_expiry).unwrap_or(chrono::Duration::hours(1)); let permissions = BlobSasPermissions { create: true, write: true, ..Default::default() }; let sas_url = self.generate_sas_url(blob_path, permissions, expiry_chrono)?; Ok(PresignedUpload { upload_url: sas_url, blob_path: blob_path.to_string(), expires_at: expiry_chrono, }) } pub fn create_read_url(&self, blob_path: &str) -> Result { let expiry = Utc::now() + chrono::Duration::minutes(5); let permissions = BlobSasPermissions { read: true, ..Default::default() }; self.generate_sas_url(blob_path, permissions, expiry) } pub async fn get_blob_properties( &self, blob_path: &str, ) -> Result { let response = self .blob_client(blob_path) .get_properties(None) .await .map_err(|e| AzureBlobError::Storage(e.to_string()))?; let content_length = response .content_length() .map_err(|e| AzureBlobError::Storage(e.to_string()))? .unwrap_or(0) as i64; Ok(BlobProperties { content_length }) } pub async fn download_blob(&self, blob_path: &str) -> Result, AzureBlobError> { let response = self .blob_client(blob_path) .download(None) .await .map_err(|e| AzureBlobError::Storage(e.to_string()))?; let bytes = response .into_body() .collect() .await .map_err(|e| AzureBlobError::Storage(e.to_string()))?; if bytes.is_empty() { return Err(AzureBlobError::NotFound(blob_path.to_string())); } Ok(bytes.to_vec()) } pub async fn upload_blob( &self, blob_path: &str, data: Vec, content_type: String, ) -> Result<(), AzureBlobError> { let len = data.len() as u64; self.blob_client(blob_path) .upload( RequestContent::from(data), true, len, Some(BlockBlobClientUploadOptions { blob_content_type: Some(content_type), ..Default::default() }), ) .await .map_err(|e| AzureBlobError::Storage(e.to_string()))?; Ok(()) } pub async fn delete_blob(&self, blob_path: &str) -> Result<(), AzureBlobError> { self.blob_client(blob_path) .delete(None) .await .map_err(|e| AzureBlobError::Storage(e.to_string()))?; Ok(()) } fn generate_sas_url( &self, blob_path: &str, permissions: BlobSasPermissions, expiry: DateTime, ) -> Result { let expiry_time = OffsetDateTime::from_unix_timestamp(expiry.timestamp()) .map_err(|e| AzureBlobError::SasToken(e.to_string()))?; let canonicalized_resource = format!( "/blob/{}/{}/{}", self.account_name, self.container_name, blob_path ); let protocol = match &self.endpoint_url { Some(url) if url.starts_with("http://") => SasProtocol::HttpHttps, _ => SasProtocol::Https, }; let sas = BlobSharedAccessSignature::new( Secret::new(self.account_key.clone()), canonicalized_resource, permissions, expiry_time, BlobSignedResource::Blob, ) .protocol(protocol); let token = sas .token() .map_err(|e| AzureBlobError::SasToken(e.to_string()))?; let base_url = match (&self.public_endpoint_url, &self.endpoint_url) { (Some(public), _) => public.trim_end_matches('/').to_string(), (None, Some(endpoint)) => endpoint.trim_end_matches('/').to_string(), (None, None) => format!("https://{}.blob.core.windows.net", self.account_name), }; Ok(format!( "{}/{}/{}?{}", base_url, self.container_name, blob_path, token )) } } // ── SAS token generation (ported from azure_storage 0.21) ──────────────────── // // https://github.com/Azure/azure-sdk-for-rust/blob/legacy/sdk/storage/src/shared_access_signature/mod.rs // // This crate has been deprecated by Azure, but SAS token generation has yet to be implemented in // the new azure_storage_blob crate, so we port the relevant code here for now. // // See: https://github.com/Azure/azure-sdk-for-rust/issues/3330 const SERVICE_SAS_VERSION: &str = "2022-11-02"; #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum SasProtocol { Https, HttpHttps, } impl fmt::Display for SasProtocol { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { SasProtocol::Https => write!(f, "https"), SasProtocol::HttpHttps => write!(f, "http,https"), } } } pub enum BlobSignedResource { Blob, BlobVersion, BlobSnapshot, Container, Directory, } impl fmt::Display for BlobSignedResource { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::Blob => write!(f, "b"), Self::BlobVersion => write!(f, "bv"), Self::BlobSnapshot => write!(f, "bs"), Self::Container => write!(f, "c"), Self::Directory => write!(f, "d"), } } } #[allow(clippy::struct_excessive_bools)] #[derive(Default)] pub struct BlobSasPermissions { pub read: bool, pub add: bool, pub create: bool, pub write: bool, pub delete: bool, pub delete_version: bool, pub permanent_delete: bool, pub list: bool, pub tags: bool, pub move_: bool, pub execute: bool, pub ownership: bool, pub permissions: bool, } impl fmt::Display for BlobSasPermissions { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if self.read { write!(f, "r")?; } if self.add { write!(f, "a")?; } if self.create { write!(f, "c")?; } if self.write { write!(f, "w")?; } if self.delete { write!(f, "d")?; } if self.delete_version { write!(f, "x")?; } if self.permanent_delete { write!(f, "y")?; } if self.list { write!(f, "l")?; } if self.tags { write!(f, "t")?; } if self.move_ { write!(f, "m")?; } if self.execute { write!(f, "e")?; } if self.ownership { write!(f, "o")?; } if self.permissions { write!(f, "p")?; } Ok(()) } } pub struct BlobSharedAccessSignature { key: Secret, canonicalized_resource: String, resource: BlobSignedResource, permissions: BlobSasPermissions, expiry: OffsetDateTime, protocol: Option, } impl BlobSharedAccessSignature { pub fn new( key: Secret, canonicalized_resource: String, permissions: BlobSasPermissions, expiry: OffsetDateTime, resource: BlobSignedResource, ) -> Self { Self { key, canonicalized_resource, resource, permissions, expiry, protocol: None, } } pub fn protocol(mut self, protocol: SasProtocol) -> Self { self.protocol = Some(protocol); self } fn sign(&self) -> String { let content = [ self.permissions.to_string(), String::new(), // start time format_sas_date(self.expiry), self.canonicalized_resource.clone(), String::new(), // identifier String::new(), // ip self.protocol.map(|x| x.to_string()).unwrap_or_default(), SERVICE_SAS_VERSION.to_string(), self.resource.to_string(), String::new(), // snapshot time String::new(), // signed encryption scope String::new(), // signed cache control String::new(), // signed content disposition String::new(), // signed content encoding String::new(), // signed content language String::new(), // signed content type ]; sas_hmac_sha256(&content.join("\n"), &self.key) } pub fn token(&self) -> Result { let mut form = form_urlencoded::Serializer::new(String::new()); form.extend_pairs(&[ ("sv", SERVICE_SAS_VERSION), ("sp", &self.permissions.to_string()), ("sr", &self.resource.to_string()), ("se", &format_sas_date(self.expiry)), ]); if let Some(protocol) = &self.protocol { form.append_pair("spr", &protocol.to_string()); } let sig = self.sign(); form.append_pair("sig", &sig); Ok(form.finish()) } } fn format_sas_date(d: OffsetDateTime) -> String { // Truncate nanoseconds to match Azure's canonicalization. let d = d.replace_nanosecond(0).unwrap(); d.format(&time::format_description::well_known::Rfc3339) .unwrap() } fn sas_hmac_sha256(data: &str, key: &Secret) -> String { let key_bytes = BASE64_STANDARD.decode(key.secret()).unwrap(); let mut hmac = Hmac::::new_from_slice(&key_bytes).unwrap(); hmac.update(data.as_bytes()); BASE64_STANDARD.encode(hmac.finalize().into_bytes()) } #[cfg(test)] mod tests { use time::Duration; use super::*; const MOCK_SECRET_KEY: &str = "RZfi3m1W7eyQ5zD4ymSmGANVdJ2SDQmg4sE89SW104s="; const MOCK_CANONICALIZED_RESOURCE: &str = "/blob/STORAGE_ACCOUNT_NAME/CONTAINER_NAME/"; #[test] fn test_blob_scoped_sas_token() { let permissions = BlobSasPermissions { read: true, ..Default::default() }; let signed_token = BlobSharedAccessSignature::new( Secret::new(MOCK_SECRET_KEY), String::from(MOCK_CANONICALIZED_RESOURCE), permissions, OffsetDateTime::UNIX_EPOCH + Duration::days(7), BlobSignedResource::Blob, ) .token() .unwrap(); assert_eq!( signed_token, "sv=2022-11-02&sp=r&sr=b&se=1970-01-08T00%3A00%3A00Z&sig=VRZjVZ1c%2FLz7IXCp17Sdx9%2BR9JDrnJdzE3NW56DMjNs%3D" ); let parsed = url::form_urlencoded::parse(signed_token.as_bytes()); assert!(parsed.clone().any(|(k, v)| k == "sr" && v == "b")); assert!(!parsed.clone().any(|(k, _)| k == "sdd")); } #[test] fn test_directory_scoped_sas_token() { let permissions = BlobSasPermissions { read: true, ..Default::default() }; let signed_token = BlobSharedAccessSignature::new( Secret::new(MOCK_SECRET_KEY), String::from(MOCK_CANONICALIZED_RESOURCE), permissions, OffsetDateTime::UNIX_EPOCH + Duration::days(7), BlobSignedResource::Directory, ) .token() .unwrap(); // The directory test from the original just checks sr=d is present let parsed = url::form_urlencoded::parse(signed_token.as_bytes()); assert!(parsed.clone().any(|(k, v)| k == "sr" && v == "d")); } } ================================================ FILE: crates/remote/src/billing.rs ================================================ #[cfg(feature = "vk-billing")] use std::sync::Arc; #[cfg(feature = "vk-billing")] pub use billing::{ BillingError, BillingProvider, BillingStatus, BillingStatusResponse, CreateCheckoutRequest, CreatePortalRequest, }; use uuid::Uuid; #[derive(Clone)] pub struct BillingService { #[cfg(feature = "vk-billing")] provider: Option>, } impl BillingService { #[cfg(feature = "vk-billing")] pub fn new(provider: Option>) -> Self { Self { provider } } #[cfg(not(feature = "vk-billing"))] pub fn new() -> Self { Self {} } /// Returns Ok(()) if billing allows adding a member, or if billing is disabled/not configured. pub async fn can_add_member(&self, _org_id: Uuid) -> Result<(), BillingCheckError> { #[cfg(feature = "vk-billing")] if let Some(provider) = &self.provider { provider .can_add_member(_org_id) .await .map_err(BillingCheckError::Billing)?; } Ok(()) } /// Notifies billing of member count changes. No-op if billing disabled. pub async fn on_member_count_changed(&self, _org_id: Uuid) { #[cfg(feature = "vk-billing")] if let Some(provider) = &self.provider { if let Err(e) = provider.on_member_count_changed(_org_id).await { tracing::warn!(?e, %_org_id, "Failed to notify billing of member count change"); } } } pub fn is_configured(&self) -> bool { #[cfg(feature = "vk-billing")] { self.provider.is_some() } #[cfg(not(feature = "vk-billing"))] { false } } /// Returns the billing provider if configured. #[cfg(feature = "vk-billing")] pub fn provider(&self) -> Option> { self.provider.clone() } /// Returns None when billing feature is disabled. #[cfg(not(feature = "vk-billing"))] pub fn provider(&self) -> Option { None } } #[cfg(not(feature = "vk-billing"))] impl Default for BillingService { fn default() -> Self { Self::new() } } #[derive(Debug)] pub enum BillingCheckError { #[cfg(feature = "vk-billing")] Billing(BillingError), } impl std::fmt::Display for BillingCheckError { fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { #[cfg(feature = "vk-billing")] match self { Self::Billing(e) => write!(_f, "{}", e), } #[cfg(not(feature = "vk-billing"))] { match *self {} } } } impl std::error::Error for BillingCheckError {} impl BillingCheckError { pub fn to_error_response(&self, _context: &str) -> crate::routes::error::ErrorResponse { #[cfg(feature = "vk-billing")] { use axum::http::StatusCode; use crate::routes::error::ErrorResponse; match self { Self::Billing(e) => match e { BillingError::SubscriptionRequired(_) | BillingError::SubscriptionInactive => { ErrorResponse::new( StatusCode::PAYMENT_REQUIRED, format!("{}: {}. Subscribe to add more members.", _context, e), ) } BillingError::Stripe(msg) => { tracing::error!(?msg, "Stripe error"); ErrorResponse::new(StatusCode::BAD_GATEWAY, "Payment provider error") } BillingError::Database(db_err) => { tracing::error!(?db_err, "Database error in billing check"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Internal error") } BillingError::NotConfigured => ErrorResponse::new( StatusCode::SERVICE_UNAVAILABLE, "Billing not configured", ), BillingError::OrganizationNotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } }, } } #[cfg(not(feature = "vk-billing"))] { match *self {} } } } ================================================ FILE: crates/remote/src/bin/generate_types.rs ================================================ use std::{env, fs, path::Path}; use api_types::{ Attachment, AttachmentUrlResponse, AttachmentWithBlob, Blob, CreateIssueAssigneeRequest, CreateIssueCommentReactionRequest, CreateIssueCommentRequest, CreateIssueFollowerRequest, CreateIssueRelationshipRequest, CreateIssueRequest, CreateIssueTagRequest, CreateProjectRequest, CreateProjectStatusRequest, CreateTagRequest, Issue, IssueAssignee, IssueComment, IssueCommentReaction, IssueFollower, IssuePriority, IssueRelationship, IssueRelationshipType, IssueSortField, IssueTag, ListIssuesQuery, ListIssuesResponse, ListRelayHostsResponse, MemberRole, Notification, NotificationGroupKind, NotificationPayload, NotificationType, OrganizationMember, Project, ProjectStatus, PullRequest, PullRequestStatus, RelayHost, RelaySession, RelaySessionAuthCodeResponse, SearchIssuesRequest, SortDirection, Tag, UpdateIssueCommentReactionRequest, UpdateIssueCommentRequest, UpdateIssueRequest, UpdateNotificationRequest, UpdateProjectRequest, UpdateProjectStatusRequest, UpdateTagRequest, User, UserData, Workspace, }; use remote::{ routes::{ all_mutation_definitions, attachments::{ CommitAttachmentsRequest, CommitAttachmentsResponse, ConfirmUploadRequest, InitUploadRequest, InitUploadResponse, }, hosts::CreateRelaySessionResponse, }, shape_routes::all_shape_routes, }; use ts_rs::TS; fn main() { let args: Vec = env::args().collect(); let check_mode = args.iter().any(|arg| arg == "--check"); let typescript = export_shapes(); // Path to shared/remote-types.ts relative to workspace root let output_path = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() // crates/ .unwrap() .parent() // workspace root .unwrap() .join("shared/remote-types.ts"); if check_mode { let current = fs::read_to_string(&output_path).unwrap_or_default(); if current == typescript { println!("✅ shared/remote-types.ts is up to date."); std::process::exit(0); } else { eprintln!("❌ shared/remote-types.ts is not up to date."); eprintln!("Please run 'pnpm run remote:generate-types' and commit the changes."); std::process::exit(1); } } else { fs::write(&output_path, &typescript).expect("Failed to write remote-types.ts"); println!( "✅ Generated remote types and shapes to {}", output_path.display() ); } } fn export_shapes() -> String { let routes = all_shape_routes(); let mut output = String::new(); // Header output.push_str("// This file was auto-generated by generate_types in the remote crate.\n"); output.push_str("// Do not edit manually.\n\n"); // Generate type declarations for all Electric types output.push_str("// Electric row types\n"); let type_decls = vec![ serde_json::Value::decl(), Project::decl(), Notification::decl(), NotificationGroupKind::decl(), NotificationPayload::decl(), NotificationType::decl(), Workspace::decl(), ProjectStatus::decl(), Tag::decl(), Issue::decl(), IssueAssignee::decl(), Blob::decl(), Attachment::decl(), AttachmentWithBlob::decl(), IssueFollower::decl(), IssueTag::decl(), IssueRelationship::decl(), IssueRelationshipType::decl(), IssueComment::decl(), IssueCommentReaction::decl(), IssuePriority::decl(), IssueSortField::decl(), ListIssuesQuery::decl(), SearchIssuesRequest::decl(), ListIssuesResponse::decl(), PullRequestStatus::decl(), PullRequest::decl(), SortDirection::decl(), UserData::decl(), User::decl(), RelayHost::decl(), ListRelayHostsResponse::decl(), RelaySession::decl(), CreateRelaySessionResponse::decl(), RelaySessionAuthCodeResponse::decl(), MemberRole::decl(), OrganizationMember::decl(), // Mutation request types CreateProjectRequest::decl(), UpdateProjectRequest::decl(), UpdateNotificationRequest::decl(), CreateTagRequest::decl(), UpdateTagRequest::decl(), CreateProjectStatusRequest::decl(), UpdateProjectStatusRequest::decl(), CreateIssueRequest::decl(), UpdateIssueRequest::decl(), CreateIssueAssigneeRequest::decl(), CreateIssueFollowerRequest::decl(), CreateIssueTagRequest::decl(), CreateIssueRelationshipRequest::decl(), CreateIssueCommentRequest::decl(), UpdateIssueCommentRequest::decl(), CreateIssueCommentReactionRequest::decl(), UpdateIssueCommentReactionRequest::decl(), // Attachment API request/response types InitUploadRequest::decl(), InitUploadResponse::decl(), ConfirmUploadRequest::decl(), CommitAttachmentsRequest::decl(), CommitAttachmentsResponse::decl(), AttachmentUrlResponse::decl(), ]; for decl in type_decls { let trimmed = decl.trim_start(); if trimmed.starts_with("export") { output.push_str(&decl); } else { output.push_str("export "); output.push_str(trimmed); } output.push_str("\n\n"); } // ShapeDefinition interface output.push_str("// Shape definition interface\n"); output.push_str("export interface ShapeDefinition {\n"); output.push_str(" readonly table: string;\n"); output.push_str(" readonly params: readonly string[];\n"); output.push_str(" readonly url: string;\n"); output.push_str(" readonly fallbackUrl: string;\n"); output.push_str( " readonly _type: T; // Phantom field for type inference (not present at runtime)\n", ); output.push_str("}\n\n"); // Helper function output.push_str("// Helper to create type-safe shape definitions\n"); output.push_str("function defineShape(\n"); output.push_str(" table: string,\n"); output.push_str(" params: readonly string[],\n"); output.push_str(" url: string,\n"); output.push_str(" fallbackUrl: string\n"); output.push_str("): ShapeDefinition {\n"); output.push_str(" return { table, params, url, fallbackUrl } as ShapeDefinition;\n"); output.push_str("}\n\n"); // Generate individual shape definitions output.push_str("// Individual shape definitions with embedded types\n"); for route in &routes { let shape = route.shape; let name = shape.name(); let params_str = shape .params() .iter() .map(|p| format!("'{}'", p)) .collect::>() .join(", "); output.push_str(&format!( "export const {} = defineShape<{}>(\n '{}',\n [{}] as const,\n '/v1{}',\n '/v1{}'\n);\n\n", name, shape.ts_type_name(), shape.table(), params_str, shape.url(), route.fallback_url, )); } output.push_str( "// =============================================================================\n", ); output.push_str("// Mutation Definitions\n"); output.push_str( "// =============================================================================\n\n", ); // MutationDefinition interface output.push_str("// Mutation definition interface\n"); output.push_str( "export interface MutationDefinition {\n", ); output.push_str(" readonly name: string;\n"); output.push_str(" readonly url: string;\n"); output.push_str( " readonly _rowType: TRow; // Phantom field for type inference (not present at runtime)\n", ); output.push_str(" readonly _createType: TCreate; // Phantom field for type inference (not present at runtime)\n"); output.push_str(" readonly _updateType: TUpdate; // Phantom field for type inference (not present at runtime)\n"); output.push_str("}\n\n"); // Helper function output.push_str("// Helper to create type-safe mutation definitions\n"); output.push_str("function defineMutation(\n"); output.push_str(" name: string,\n"); output.push_str(" url: string\n"); output.push_str("): MutationDefinition {\n"); output.push_str(" return { name, url } as MutationDefinition;\n"); output.push_str("}\n\n"); // Generate individual mutation definitions output.push_str("// Individual mutation definitions\n"); for mutation in all_mutation_definitions() { let ts_type = &mutation.row_type; let const_name = to_screaming_snake_case(ts_type); let create_type = mutation.create_type.as_deref().unwrap_or("unknown"); let update_type = mutation.update_type.as_deref().unwrap_or("unknown"); output.push_str(&format!( "export const {}_MUTATION = defineMutation<{}, {}, {}>(\n '{}',\n '/v1/{}'\n);\n\n", const_name, ts_type, create_type, update_type, ts_type, mutation.table, )); } // Type helpers output.push_str("// Type helpers to extract types from a mutation definition\n"); output.push_str("export type MutationRowType> = M extends MutationDefinition ? R : never;\n"); output.push_str("export type MutationCreateType> = M extends MutationDefinition ? C : never;\n"); output.push_str("export type MutationUpdateType> = M extends MutationDefinition ? U : never;\n"); output } /// Convert PascalCase to SCREAMING_SNAKE_CASE fn to_screaming_snake_case(s: &str) -> String { let mut result = String::new(); for (i, c) in s.chars().enumerate() { if c.is_uppercase() && i > 0 { result.push('_'); } result.push(c.to_ascii_uppercase()); } result } ================================================ FILE: crates/remote/src/config.rs ================================================ use std::env; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use secrecy::SecretString; use thiserror::Error; #[derive(Debug, Clone)] pub struct RemoteServerConfig { pub database_url: String, pub listen_addr: String, pub server_public_base_url: Option, pub auth: AuthConfig, pub electric_url: String, pub electric_secret: Option, pub electric_role_password: Option, pub electric_publication_names: Vec, pub r2: Option, pub azure_blob: Option, pub review_worker_base_url: Option, pub review_disabled: bool, pub github_app: Option, } #[derive(Debug, Clone)] pub struct R2Config { pub access_key_id: String, pub secret_access_key: SecretString, pub endpoint: String, pub bucket: String, pub presign_expiry_secs: u64, } impl R2Config { pub fn from_env() -> Result, ConfigError> { let access_key_id = match env::var("R2_ACCESS_KEY_ID") { Ok(v) if !v.is_empty() => v, _ => { tracing::info!("R2_ACCESS_KEY_ID not set, R2 storage disabled"); return Ok(None); } }; tracing::info!("R2_ACCESS_KEY_ID is set, checking other R2 env vars"); let secret_access_key = env::var("R2_SECRET_ACCESS_KEY") .map_err(|_| ConfigError::MissingVar("R2_SECRET_ACCESS_KEY"))?; let endpoint = env::var("R2_REVIEW_ENDPOINT") .map_err(|_| ConfigError::MissingVar("R2_REVIEW_ENDPOINT"))?; let bucket = env::var("R2_REVIEW_BUCKET") .map_err(|_| ConfigError::MissingVar("R2_REVIEW_BUCKET"))?; let presign_expiry_secs = env::var("R2_PRESIGN_EXPIRY_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3600); tracing::info!(endpoint = %endpoint, bucket = %bucket, "R2 config loaded successfully"); Ok(Some(Self { access_key_id, secret_access_key: SecretString::new(secret_access_key.into()), endpoint, bucket, presign_expiry_secs, })) } } #[derive(Debug, Clone)] pub enum AzureAuthMode { /// Entra ID via user-assigned managed identity (production). EntraId { client_id: String }, /// Shared Key via custom HMAC policy (local Azurite). SharedKey, } #[derive(Debug, Clone)] pub struct AzureBlobConfig { pub account_name: String, /// Account key is always required for SAS token generation. pub account_key: SecretString, pub container_name: String, pub endpoint_url: Option, pub public_endpoint_url: Option, pub presign_expiry_secs: u64, pub auth_mode: AzureAuthMode, } impl AzureBlobConfig { pub fn from_env() -> Result, ConfigError> { let account_name = match env::var("AZURE_STORAGE_ACCOUNT_NAME") { Ok(v) => v, Err(_) => { tracing::info!("AZURE_STORAGE_ACCOUNT_NAME not set, Azure Blob storage disabled"); return Ok(None); } }; tracing::info!("AZURE_STORAGE_ACCOUNT_NAME is set, checking other Azure Blob env vars"); let account_key = env::var("AZURE_STORAGE_ACCOUNT_KEY") .map_err(|_| ConfigError::MissingVar("AZURE_STORAGE_ACCOUNT_KEY"))?; let container_name = env::var("AZURE_STORAGE_CONTAINER_NAME") .unwrap_or_else(|_| "issue-attachments".to_string()); let endpoint_url = env::var("AZURE_STORAGE_ENDPOINT_URL").ok(); let public_endpoint_url = env::var("AZURE_STORAGE_PUBLIC_ENDPOINT_URL").ok(); let auth_mode = match env::var("AZURE_MANAGED_IDENTITY_CLIENT_ID") { Ok(client_id) => AzureAuthMode::EntraId { client_id }, Err(_) => AzureAuthMode::SharedKey, }; let presign_expiry_secs = env::var("AZURE_BLOB_PRESIGN_EXPIRY_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(3600); tracing::info!( account_name = %account_name, container_name = %container_name, endpoint_url = ?endpoint_url, auth_mode = ?auth_mode, "Azure Blob config loaded successfully" ); Ok(Some(Self { account_name, account_key: SecretString::new(account_key.into()), container_name, endpoint_url, public_endpoint_url, presign_expiry_secs, auth_mode, })) } } #[derive(Debug, Clone)] pub struct GitHubAppConfig { pub app_id: u64, pub private_key: SecretString, // Base64-encoded PEM pub webhook_secret: SecretString, pub app_slug: String, } impl GitHubAppConfig { pub fn from_env() -> Result, ConfigError> { let app_id = match env::var("GITHUB_APP_ID") { Ok(v) if !v.is_empty() => v, _ => { tracing::info!("GITHUB_APP_ID not set, GitHub App integration disabled"); return Ok(None); } }; let app_id: u64 = app_id .parse() .map_err(|_| ConfigError::InvalidVar("GITHUB_APP_ID"))?; tracing::info!("GITHUB_APP_ID is set, checking other GitHub App env vars"); let private_key = env::var("GITHUB_APP_PRIVATE_KEY") .map_err(|_| ConfigError::MissingVar("GITHUB_APP_PRIVATE_KEY"))?; // Validate that the private key is valid base64 BASE64_STANDARD .decode(private_key.as_bytes()) .map_err(|_| ConfigError::InvalidVar("GITHUB_APP_PRIVATE_KEY"))?; let webhook_secret = env::var("GITHUB_APP_WEBHOOK_SECRET") .map_err(|_| ConfigError::MissingVar("GITHUB_APP_WEBHOOK_SECRET"))?; let app_slug = env::var("GITHUB_APP_SLUG").map_err(|_| ConfigError::MissingVar("GITHUB_APP_SLUG"))?; tracing::info!(app_id = %app_id, app_slug = %app_slug, "GitHub App config loaded successfully"); Ok(Some(Self { app_id, private_key: SecretString::new(private_key.into()), webhook_secret: SecretString::new(webhook_secret.into()), app_slug, })) } } #[derive(Debug, Error)] pub enum ConfigError { #[error("environment variable `{0}` is not set")] MissingVar(&'static str), #[error("invalid value for environment variable `{0}`")] InvalidVar(&'static str), #[error("no OAuth providers configured")] NoOAuthProviders, } impl RemoteServerConfig { pub fn from_env() -> Result { let database_url = env::var("SERVER_DATABASE_URL") .or_else(|_| env::var("DATABASE_URL")) .map_err(|_| ConfigError::MissingVar("SERVER_DATABASE_URL"))?; let listen_addr = env::var("SERVER_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8081".to_string()); let server_public_base_url = env::var("SERVER_PUBLIC_BASE_URL").ok(); let auth = AuthConfig::from_env()?; let electric_url = env::var("ELECTRIC_URL").map_err(|_| ConfigError::MissingVar("ELECTRIC_URL"))?; let electric_secret = env::var("ELECTRIC_SECRET") .map(|s| SecretString::new(s.into())) .ok(); let electric_role_password = env::var("ELECTRIC_ROLE_PASSWORD") .ok() .map(|s| SecretString::new(s.into())); let electric_publication_names = match env::var("ELECTRIC_PUBLICATION_NAMES") { Ok(value) => parse_publication_names(&value)?, Err(_) => Vec::new(), }; let r2 = R2Config::from_env()?; let azure_blob = AzureBlobConfig::from_env()?; let review_worker_base_url = env::var("REVIEW_WORKER_BASE_URL").ok(); let review_disabled = env::var("REVIEW_DISABLED") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); let github_app = GitHubAppConfig::from_env()?; Ok(Self { database_url, listen_addr, server_public_base_url, auth, electric_url, electric_secret, electric_role_password, electric_publication_names, r2, azure_blob, review_worker_base_url, review_disabled, github_app, }) } } fn parse_publication_names(value: &str) -> Result, ConfigError> { let mut names = Vec::new(); for raw in value.split(',') { let name = raw.trim(); if name.is_empty() { continue; } if !is_valid_identifier(name) { return Err(ConfigError::InvalidVar("ELECTRIC_PUBLICATION_NAMES")); } names.push(name.to_string()); } Ok(names) } fn is_valid_identifier(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { return false; }; if !(first.is_ascii_alphabetic() || first == '_') { return false; } chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } #[derive(Debug, Clone)] pub struct OAuthProviderConfig { client_id: String, client_secret: SecretString, } impl OAuthProviderConfig { fn new(client_id: String, client_secret: SecretString) -> Self { Self { client_id, client_secret, } } pub fn client_id(&self) -> &str { &self.client_id } pub fn client_secret(&self) -> &SecretString { &self.client_secret } } #[derive(Debug, Clone)] pub struct AuthConfig { github: Option, google: Option, jwt_secret: SecretString, public_base_url: String, } impl AuthConfig { fn from_env() -> Result { let jwt_secret = env::var("VIBEKANBAN_REMOTE_JWT_SECRET") .map_err(|_| ConfigError::MissingVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; validate_jwt_secret(&jwt_secret)?; let jwt_secret = SecretString::new(jwt_secret.into()); let github = match env::var("GITHUB_OAUTH_CLIENT_ID") { Ok(client_id) if !client_id.is_empty() => { let client_secret = env::var("GITHUB_OAUTH_CLIENT_SECRET") .map_err(|_| ConfigError::MissingVar("GITHUB_OAUTH_CLIENT_SECRET"))?; Some(OAuthProviderConfig::new( client_id, SecretString::new(client_secret.into()), )) } _ => None, }; let google = match env::var("GOOGLE_OAUTH_CLIENT_ID") { Ok(client_id) if !client_id.is_empty() => { let client_secret = env::var("GOOGLE_OAUTH_CLIENT_SECRET") .map_err(|_| ConfigError::MissingVar("GOOGLE_OAUTH_CLIENT_SECRET"))?; Some(OAuthProviderConfig::new( client_id, SecretString::new(client_secret.into()), )) } _ => None, }; if github.is_none() && google.is_none() { return Err(ConfigError::NoOAuthProviders); } let public_base_url = env::var("SERVER_PUBLIC_BASE_URL").unwrap_or_else(|_| "http://localhost:8081".into()); Ok(Self { github, google, jwt_secret, public_base_url, }) } pub fn github(&self) -> Option<&OAuthProviderConfig> { self.github.as_ref() } pub fn google(&self) -> Option<&OAuthProviderConfig> { self.google.as_ref() } pub fn jwt_secret(&self) -> &SecretString { &self.jwt_secret } pub fn public_base_url(&self) -> &str { &self.public_base_url } } fn validate_jwt_secret(secret: &str) -> Result<(), ConfigError> { let decoded = BASE64_STANDARD .decode(secret.as_bytes()) .map_err(|_| ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; if decoded.len() < 32 { return Err(ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET")); } Ok(()) } ================================================ FILE: crates/remote/src/db/attachments.rs ================================================ use api_types::{Attachment, AttachmentWithBlob, Blob}; use chrono::{DateTime, Utc}; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum AttachmentError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct AttachmentRepository; impl AttachmentRepository { pub async fn find_by_id<'e, E>( executor: E, id: Uuid, ) -> Result, AttachmentError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( Attachment, r#" SELECT id AS "id!: Uuid", blob_id AS "blob_id!: Uuid", issue_id AS "issue_id?: Uuid", comment_id AS "comment_id?: Uuid", created_at AS "created_at!: DateTime", expires_at AS "expires_at?: DateTime" FROM attachments WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } pub async fn find_by_id_with_blob<'e, E>( executor: E, id: Uuid, ) -> Result, AttachmentError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( AttachmentWithBlob, r#" SELECT a.id AS "id!: Uuid", a.blob_id AS "blob_id!: Uuid", a.issue_id AS "issue_id?: Uuid", a.comment_id AS "comment_id?: Uuid", a.created_at AS "created_at!: DateTime", a.expires_at AS "expires_at?: DateTime", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?" FROM attachments a INNER JOIN blobs b ON b.id = a.blob_id WHERE a.id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } pub async fn find_by_issue_id( pool: &PgPool, issue_id: Uuid, ) -> Result, AttachmentError> { let records = sqlx::query_as!( AttachmentWithBlob, r#" SELECT a.id AS "id!: Uuid", a.blob_id AS "blob_id!: Uuid", a.issue_id AS "issue_id?: Uuid", a.comment_id AS "comment_id?: Uuid", a.created_at AS "created_at!: DateTime", a.expires_at AS "expires_at?: DateTime", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?" FROM attachments a INNER JOIN blobs b ON b.id = a.blob_id WHERE a.issue_id = $1 ORDER BY a.created_at ASC "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_by_comment_id( pool: &PgPool, comment_id: Uuid, ) -> Result, AttachmentError> { let records = sqlx::query_as!( AttachmentWithBlob, r#" SELECT a.id AS "id!: Uuid", a.blob_id AS "blob_id!: Uuid", a.issue_id AS "issue_id?: Uuid", a.comment_id AS "comment_id?: Uuid", a.created_at AS "created_at!: DateTime", a.expires_at AS "expires_at?: DateTime", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?" FROM attachments a INNER JOIN blobs b ON b.id = a.blob_id WHERE a.comment_id = $1 ORDER BY a.created_at ASC "#, comment_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, blob_id: Uuid, issue_id: Option, comment_id: Option, expires_at: Option>, ) -> Result { let id = id.unwrap_or_else(Uuid::new_v4); let data = sqlx::query_as!( Attachment, r#" INSERT INTO attachments (id, blob_id, issue_id, comment_id, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id AS "id!: Uuid", blob_id AS "blob_id!: Uuid", issue_id AS "issue_id?: Uuid", comment_id AS "comment_id?: Uuid", created_at AS "created_at!: DateTime", expires_at AS "expires_at?: DateTime" "#, id, blob_id, issue_id, comment_id, expires_at ) .fetch_one(pool) .await?; Ok(data) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result, AttachmentError> { let record = sqlx::query_as!( Attachment, r#" DELETE FROM attachments WHERE id = $1 RETURNING id AS "id!: Uuid", blob_id AS "blob_id!: Uuid", issue_id AS "issue_id?: Uuid", comment_id AS "comment_id?: Uuid", created_at AS "created_at!: DateTime", expires_at AS "expires_at?: DateTime" "#, id ) .fetch_optional(pool) .await?; Ok(record) } /// Count how many attachments reference a specific blob. pub async fn count_by_blob_id(pool: &PgPool, blob_id: Uuid) -> Result { let count = sqlx::query_scalar!( r#" SELECT COUNT(*) AS "count!" FROM attachments WHERE blob_id = $1 "#, blob_id ) .fetch_one(pool) .await?; Ok(count) } /// Commit staged attachments to an issue (sets issue_id and clears expires_at). pub async fn commit_to_issue( pool: &PgPool, attachment_ids: &[Uuid], issue_id: Uuid, ) -> Result, AttachmentError> { let records = sqlx::query_as!( AttachmentWithBlob, r#" UPDATE attachments a SET issue_id = $1, expires_at = NULL FROM blobs b WHERE a.blob_id = b.id AND a.id = ANY($2) AND a.issue_id IS NULL AND a.comment_id IS NULL RETURNING a.id AS "id!: Uuid", a.blob_id AS "blob_id!: Uuid", a.issue_id AS "issue_id?: Uuid", a.comment_id AS "comment_id?: Uuid", a.created_at AS "created_at!: DateTime", a.expires_at AS "expires_at?: DateTime", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?" "#, issue_id, attachment_ids ) .fetch_all(pool) .await?; Ok(records) } /// Commit staged attachments to a comment (sets comment_id and clears expires_at). pub async fn commit_to_comment( pool: &PgPool, attachment_ids: &[Uuid], comment_id: Uuid, ) -> Result, AttachmentError> { let records = sqlx::query_as!( AttachmentWithBlob, r#" UPDATE attachments a SET comment_id = $1, expires_at = NULL FROM blobs b WHERE a.blob_id = b.id AND a.id = ANY($2) AND a.issue_id IS NULL AND a.comment_id IS NULL RETURNING a.id AS "id!: Uuid", a.blob_id AS "blob_id!: Uuid", a.issue_id AS "issue_id?: Uuid", a.comment_id AS "comment_id?: Uuid", a.created_at AS "created_at!: DateTime", a.expires_at AS "expires_at?: DateTime", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?" "#, comment_id, attachment_ids ) .fetch_all(pool) .await?; Ok(records) } /// Get the project_id for an attachment via its blob. pub async fn project_id( pool: &PgPool, attachment_id: Uuid, ) -> Result, AttachmentError> { let record = sqlx::query_scalar!( r#" SELECT b.project_id FROM attachments a INNER JOIN blobs b ON b.id = a.blob_id WHERE a.id = $1 "#, attachment_id ) .fetch_optional(pool) .await?; Ok(record) } /// Get the blob data for an attachment. pub async fn get_blob( pool: &PgPool, attachment_id: Uuid, ) -> Result, AttachmentError> { let record = sqlx::query_as!( Blob, r#" SELECT b.id AS "id!: Uuid", b.project_id AS "project_id!: Uuid", b.blob_path AS "blob_path!", b.thumbnail_blob_path AS "thumbnail_blob_path?", b.original_name AS "original_name!", b.mime_type AS "mime_type?", b.size_bytes AS "size_bytes!", b.hash AS "hash!", b.width AS "width?", b.height AS "height?", b.created_at AS "created_at!: DateTime", b.updated_at AS "updated_at!: DateTime" FROM attachments a INNER JOIN blobs b ON b.id = a.blob_id WHERE a.id = $1 "#, attachment_id ) .fetch_optional(pool) .await?; Ok(record) } /// Find attachments whose `expires_at` is in the past (abandoned staged uploads). /// Returns up to `limit` results, oldest expired first. pub async fn find_expired( pool: &PgPool, limit: i64, ) -> Result, AttachmentError> { let records = sqlx::query_as!( Attachment, r#" SELECT id AS "id!: Uuid", blob_id AS "blob_id!: Uuid", issue_id AS "issue_id?: Uuid", comment_id AS "comment_id?: Uuid", created_at AS "created_at!: DateTime", expires_at AS "expires_at?: DateTime" FROM attachments WHERE expires_at IS NOT NULL AND expires_at < NOW() ORDER BY expires_at ASC LIMIT $1 "#, limit ) .fetch_all(pool) .await?; Ok(records) } } ================================================ FILE: crates/remote/src/db/auth.rs ================================================ pub use api_types::AuthSession; use chrono::Duration; use sqlx::{PgPool, query_as}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum AuthSessionError { #[error("auth session not found")] NotFound, #[error("refresh token reused - possible theft detected")] TokenReuseDetected, #[error("token has been revoked")] TokenRevoked, #[error("token has expired")] TokenExpired, #[error("invalid token")] InvalidToken, #[error(transparent)] Database(#[from] sqlx::Error), } pub const MAX_SESSION_INACTIVITY_DURATION: Duration = Duration::days(365); pub struct AuthSessionRepository<'a> { pool: &'a PgPool, } impl<'a> AuthSessionRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn create( &self, user_id: Uuid, refresh_token_id: Option, ) -> Result { query_as!( AuthSession, r#" INSERT INTO auth_sessions (user_id, refresh_token_id) VALUES ($1, $2) RETURNING id AS "id!", user_id AS "user_id!: Uuid", created_at AS "created_at!", last_used_at AS "last_used_at?", revoked_at AS "revoked_at?", refresh_token_id AS "refresh_token_id?", refresh_token_issued_at AS "refresh_token_issued_at?" "#, user_id, refresh_token_id ) .fetch_one(self.pool) .await .map_err(AuthSessionError::from) } pub async fn get(&self, session_id: Uuid) -> Result { query_as!( AuthSession, r#" SELECT id AS "id!", user_id AS "user_id!: Uuid", created_at AS "created_at!", last_used_at AS "last_used_at?", revoked_at AS "revoked_at?", refresh_token_id AS "refresh_token_id?", refresh_token_issued_at AS "refresh_token_issued_at?" FROM auth_sessions WHERE id = $1 "#, session_id ) .fetch_optional(self.pool) .await? .ok_or(AuthSessionError::NotFound) } pub async fn touch(&self, session_id: Uuid) -> Result<(), AuthSessionError> { sqlx::query!( r#" UPDATE auth_sessions SET last_used_at = date_trunc('day', NOW()) WHERE id = $1 AND ( last_used_at IS NULL OR last_used_at < date_trunc('day', NOW()) ) "#, session_id ) .execute(self.pool) .await?; Ok(()) } pub async fn rotate_tokens( &self, session_id: Uuid, old_refresh_token_id: Uuid, new_refresh_token_id: Uuid, ) -> Result<(), AuthSessionError> { let mut tx = super::begin_tx(self.pool) .await .map_err(AuthSessionError::from)?; let updated = sqlx::query!( r#" UPDATE auth_sessions SET refresh_token_id = $3, refresh_token_issued_at = NOW() WHERE id = $1 AND refresh_token_id = $2 RETURNING user_id "#, session_id, old_refresh_token_id, new_refresh_token_id ) .fetch_optional(&mut *tx) .await .map_err(AuthSessionError::from)?; let Some(row) = updated else { tx.rollback().await.map_err(AuthSessionError::from)?; return Err(AuthSessionError::TokenReuseDetected); }; // Revoke the old refresh token sqlx::query!( r#" INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason) VALUES ($1, $2, 'token_rotation') ON CONFLICT (token_id) DO NOTHING "#, old_refresh_token_id, row.user_id ) .execute(&mut *tx) .await .map_err(AuthSessionError::from)?; tx.commit().await.map_err(AuthSessionError::from)?; Ok(()) } pub async fn set_current_refresh_token( &self, session_id: Uuid, refresh_token_id: Uuid, ) -> Result<(), AuthSessionError> { sqlx::query!( r#" UPDATE auth_sessions SET refresh_token_id = $2, refresh_token_issued_at = NOW() WHERE id = $1 "#, session_id, refresh_token_id ) .execute(self.pool) .await?; Ok(()) } pub async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result { let mut tx = super::begin_tx(self.pool) .await .map_err(AuthSessionError::from)?; sqlx::query!( r#" INSERT INTO revoked_refresh_tokens (token_id, user_id, revoked_reason) SELECT refresh_token_id, user_id, 'reuse_of_revoked_token' FROM auth_sessions WHERE user_id = $1 AND refresh_token_id IS NOT NULL ON CONFLICT (token_id) DO NOTHING "#, user_id ) .execute(&mut *tx) .await .map_err(AuthSessionError::from)?; let update_result = sqlx::query!( r#" UPDATE auth_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL "#, user_id ) .execute(&mut *tx) .await .map_err(AuthSessionError::from)?; tx.commit().await.map_err(AuthSessionError::from)?; Ok(update_result.rows_affected() as i64) } pub async fn is_refresh_token_revoked(&self, token_id: Uuid) -> Result { let result = sqlx::query!( r#" SELECT EXISTS( SELECT 1 FROM revoked_refresh_tokens WHERE token_id = $1 ) as is_revoked "#, token_id ) .fetch_one(self.pool) .await .map_err(AuthSessionError::from)?; Ok(result.is_revoked.unwrap_or(false)) } pub async fn revoke(&self, session_id: Uuid) -> Result<(), AuthSessionError> { sqlx::query!( r#" UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1 "#, session_id ) .execute(self.pool) .await?; Ok(()) } } ================================================ FILE: crates/remote/src/db/blobs.rs ================================================ use api_types::Blob; use chrono::{DateTime, Utc}; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum BlobError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct BlobRepository; impl BlobRepository { pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result, BlobError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( Blob, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", thumbnail_blob_path AS "thumbnail_blob_path?", original_name AS "original_name!", mime_type AS "mime_type?", size_bytes AS "size_bytes!", hash AS "hash!", width AS "width?", height AS "height?", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM blobs WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } /// Find a blob by its content hash within a project. pub async fn find_by_hash( pool: &PgPool, project_id: Uuid, hash: &str, ) -> Result, BlobError> { let record = sqlx::query_as!( Blob, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", thumbnail_blob_path AS "thumbnail_blob_path?", original_name AS "original_name!", mime_type AS "mime_type?", size_bytes AS "size_bytes!", hash AS "hash!", width AS "width?", height AS "height?", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM blobs WHERE project_id = $1 AND hash = $2 LIMIT 1 "#, project_id, hash ) .fetch_optional(pool) .await?; Ok(record) } #[allow(clippy::too_many_arguments)] pub async fn create( pool: &PgPool, id: Option, project_id: Uuid, blob_path: String, thumbnail_blob_path: Option, original_name: String, mime_type: Option, size_bytes: i64, hash: String, width: Option, height: Option, ) -> Result { let id = id.unwrap_or_else(Uuid::new_v4); let data = sqlx::query_as!( Blob, r#" INSERT INTO blobs ( id, project_id, blob_path, thumbnail_blob_path, original_name, mime_type, size_bytes, hash, width, height ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (blob_path) DO UPDATE SET updated_at = NOW() RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", thumbnail_blob_path AS "thumbnail_blob_path?", original_name AS "original_name!", mime_type AS "mime_type?", size_bytes AS "size_bytes!", hash AS "hash!", width AS "width?", height AS "height?", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id, project_id, blob_path, thumbnail_blob_path, original_name, mime_type, size_bytes, hash, width, height, ) .fetch_one(pool) .await?; Ok(data) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result, BlobError> { let record = sqlx::query_as!( Blob, r#" DELETE FROM blobs WHERE id = $1 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", thumbnail_blob_path AS "thumbnail_blob_path?", original_name AS "original_name!", mime_type AS "mime_type?", size_bytes AS "size_bytes!", hash AS "hash!", width AS "width?", height AS "height?", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id ) .fetch_optional(pool) .await?; Ok(record) } /// Get the organization_id for a blob via its project. pub async fn organization_id(pool: &PgPool, blob_id: Uuid) -> Result, BlobError> { let record = sqlx::query_scalar!( r#" SELECT p.organization_id FROM blobs b INNER JOIN projects p ON p.id = b.project_id WHERE b.id = $1 "#, blob_id ) .fetch_optional(pool) .await?; Ok(record) } } ================================================ FILE: crates/remote/src/db/digest.rs ================================================ use api_types::{NotificationPayload, NotificationType}; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Postgres, pool::PoolConnection}; use uuid::Uuid; use crate::digest::DigestUser; #[derive(Debug, Clone)] pub struct NotificationDigestRow { pub id: Uuid, pub notification_type: NotificationType, pub payload: sqlx::types::Json, pub issue_id: Option, pub created_at: DateTime, pub actor_name: String, } pub struct DigestRepository; const DIGEST_ADVISORY_LOCK_ID: i64 = 3_447_201_001; pub struct DigestRunLock { connection: PoolConnection, } impl DigestRepository { pub async fn try_acquire_run_lock(pool: &PgPool) -> Result, sqlx::Error> { let mut connection = pool.acquire().await?; let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)") .bind(DIGEST_ADVISORY_LOCK_ID) .fetch_one(&mut *connection) .await?; if acquired { Ok(Some(DigestRunLock { connection })) } else { Ok(None) } } pub async fn fetch_users_with_pending_notifications( pool: &PgPool, window_start: DateTime, window_end: DateTime, ) -> Result, sqlx::Error> { sqlx::query_as!( DigestUser, r#" SELECT DISTINCT u.id AS "id!: Uuid", u.email AS "email!", u.first_name, u.last_name, u.username FROM notifications n JOIN users u ON u.id = n.user_id WHERE n.created_at >= $1 AND n.created_at < $2 AND n.dismissed_at IS NULL AND n.seen = FALSE AND NOT EXISTS ( SELECT 1 FROM notification_digest_deliveries d WHERE d.notification_id = n.id ) ORDER BY u.id "#, window_start, window_end ) .fetch_all(pool) .await } pub async fn fetch_notifications_for_user( pool: &PgPool, user_id: Uuid, window_start: DateTime, window_end: DateTime, ) -> Result, sqlx::Error> { sqlx::query_as!( NotificationDigestRow, r#" SELECT n.id AS "id!: Uuid", n.notification_type AS "notification_type!: NotificationType", n.payload AS "payload!: sqlx::types::Json", n.issue_id AS "issue_id?: Uuid", n.created_at AS "created_at!", COALESCE(NULLIF(actor.first_name, ''), NULLIF(actor.username, ''), 'Someone') AS "actor_name!" FROM notifications n LEFT JOIN users actor ON actor.id = NULLIF(n.payload->>'actor_user_id', '')::uuid WHERE n.user_id = $1 AND n.created_at >= $2 AND n.created_at < $3 AND n.dismissed_at IS NULL AND n.seen = FALSE AND NOT EXISTS ( SELECT 1 FROM notification_digest_deliveries d WHERE d.notification_id = n.id ) ORDER BY n.created_at DESC "#, user_id, window_start, window_end ) .fetch_all(pool) .await } pub async fn record_notifications_delivered( pool: &PgPool, notification_ids: &[Uuid], ) -> Result<(), sqlx::Error> { if notification_ids.is_empty() { return Ok(()); } sqlx::query!( r#" INSERT INTO notification_digest_deliveries (notification_id) SELECT notification_id FROM UNNEST($1::uuid[]) AS delivered(notification_id) ON CONFLICT (notification_id) DO NOTHING "#, notification_ids, ) .execute(pool) .await?; Ok(()) } } impl DigestRunLock { pub async fn release(mut self) -> Result<(), sqlx::Error> { sqlx::query("SELECT pg_advisory_unlock($1)") .bind(DIGEST_ADVISORY_LOCK_ID) .execute(&mut *self.connection) .await?; Ok(()) } } ================================================ FILE: crates/remote/src/db/electric_publications.rs ================================================ use std::collections::HashSet; use sqlx::PgPool; #[derive(Debug)] struct PublicationTable { schema_name: String, table_name: String, } #[derive(Debug, Hash, PartialEq, Eq)] struct PublicationTableRef { pubname: String, schema_name: String, table_name: String, } pub(crate) async fn ensure_electric_publications( pool: &PgPool, publication_names: &[String], ) -> Result<(), sqlx::Error> { if publication_names.is_empty() { return Ok(()); } tracing::info!( publication_count = publication_names.len(), publications = ?publication_names, "Electric publication sync starting" ); let mut tx = super::begin_tx(pool).await?; sqlx::query(r#"SELECT pg_advisory_xact_lock(hashtext('electric_publication_sync'))"#) .execute(&mut *tx) .await?; let existing_publications = sqlx::query_scalar!( r#"SELECT pubname FROM pg_publication WHERE pubname = ANY($1)"#, publication_names ) .fetch_all(&mut *tx) .await?; let existing_publications: HashSet = existing_publications.into_iter().collect(); let mut created_publications = Vec::new(); let mut skipped_publications = Vec::new(); for publication in publication_names { if !existing_publications.contains(publication) { let sql = format!("CREATE PUBLICATION {}", quote_ident(publication)); sqlx::query(&sql).execute(&mut *tx).await?; created_publications.push(publication.clone()); } else { skipped_publications.push(publication.clone()); } } if !created_publications.is_empty() { tracing::info!(publications = ?created_publications, "Created missing Electric publications"); } if !skipped_publications.is_empty() { tracing::info!(publications = ?skipped_publications, "Electric publications already exist (skipped)"); } let tables = sqlx::query_as!( PublicationTable, r#"SELECT n.nspname AS schema_name, c.relname AS table_name FROM pg_publication_rel pr JOIN pg_publication p ON pr.prpubid = p.oid JOIN pg_class c ON pr.prrelid = c.oid JOIN pg_namespace n ON c.relnamespace = n.oid WHERE p.pubname = 'electric_publication_default'"# ) .fetch_all(&mut *tx) .await?; tracing::info!( default_table_count = tables.len(), "Loaded tables from electric_publication_default" ); let existing_pairs = sqlx::query_as!( PublicationTableRef, r#"SELECT p.pubname AS pubname, n.nspname AS schema_name, c.relname AS table_name FROM pg_publication_rel pr JOIN pg_publication p ON pr.prpubid = p.oid JOIN pg_class c ON pr.prrelid = c.oid JOIN pg_namespace n ON c.relnamespace = n.oid WHERE p.pubname = ANY($1)"#, publication_names ) .fetch_all(&mut *tx) .await?; let existing_pairs: HashSet = existing_pairs.into_iter().collect(); let mut missing_pairs = Vec::new(); for table in &tables { for publication in publication_names { let key = PublicationTableRef { pubname: publication.clone(), schema_name: table.schema_name.clone(), table_name: table.table_name.clone(), }; if !existing_pairs.contains(&key) { missing_pairs.push(key); } } } tracing::info!( missing_pair_count = missing_pairs.len(), "Computed missing publication/table mappings" ); for entry in &missing_pairs { let sql = format!( "ALTER PUBLICATION {} ADD TABLE {}.{}", quote_ident(&entry.pubname), quote_ident(&entry.schema_name), quote_ident(&entry.table_name) ); sqlx::query(&sql).execute(&mut *tx).await?; } if !missing_pairs.is_empty() { tracing::info!( added_pair_count = missing_pairs.len(), "Added missing tables to Electric publications" ); } else { tracing::info!("No missing tables to add to Electric publications"); } tx.commit().await?; tracing::info!("Electric publication sync completed"); Ok(()) } fn quote_ident(ident: &str) -> String { format!("\"{}\"", ident.replace('\"', "\"\"")) } ================================================ FILE: crates/remote/src/db/github_app.rs ================================================ use chrono::{DateTime, Utc}; use sqlx::{FromRow, PgPool}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum GitHubAppDbError { #[error("database error: {0}")] Database(#[from] sqlx::Error), #[error("installation not found")] NotFound, #[error("pending installation not found or expired")] PendingNotFound, } /// A GitHub App installation linked to an organization #[derive(Debug, Clone, FromRow)] pub struct GitHubAppInstallation { pub id: Uuid, pub organization_id: Uuid, pub github_installation_id: i64, pub github_account_login: String, pub github_account_type: String, pub repository_selection: String, pub installed_by_user_id: Option, pub suspended_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } /// A repository accessible via an installation #[derive(Debug, Clone, FromRow)] pub struct GitHubAppRepository { pub id: Uuid, pub installation_id: Uuid, pub github_repo_id: i64, pub repo_full_name: String, pub review_enabled: bool, pub created_at: DateTime, } /// A pending installation waiting for callback #[derive(Debug, Clone, FromRow)] pub struct PendingInstallation { pub id: Uuid, pub organization_id: Uuid, pub user_id: Uuid, pub state_token: String, pub expires_at: DateTime, pub created_at: DateTime, } pub struct GitHubAppRepository2<'a> { pool: &'a PgPool, } impl<'a> GitHubAppRepository2<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } // ========== Installations ========== pub async fn create_installation( &self, organization_id: Uuid, github_installation_id: i64, github_account_login: &str, github_account_type: &str, repository_selection: &str, installed_by_user_id: Uuid, ) -> Result { let installation = sqlx::query_as!( GitHubAppInstallation, r#" INSERT INTO github_app_installations ( organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (github_installation_id) DO UPDATE SET organization_id = EXCLUDED.organization_id, github_account_login = EXCLUDED.github_account_login, github_account_type = EXCLUDED.github_account_type, repository_selection = EXCLUDED.repository_selection, installed_by_user_id = EXCLUDED.installed_by_user_id, suspended_at = NULL, updated_at = NOW() RETURNING id, organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id, suspended_at, created_at, updated_at "#, organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id ) .fetch_one(self.pool) .await?; Ok(installation) } pub async fn get_by_github_id( &self, github_installation_id: i64, ) -> Result, GitHubAppDbError> { let installation = sqlx::query_as!( GitHubAppInstallation, r#" SELECT id, organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id, suspended_at, created_at, updated_at FROM github_app_installations WHERE github_installation_id = $1 "#, github_installation_id ) .fetch_optional(self.pool) .await?; Ok(installation) } /// Find an installation by the GitHub account login (owner name) pub async fn get_by_account_login( &self, account_login: &str, ) -> Result, GitHubAppDbError> { let installation = sqlx::query_as!( GitHubAppInstallation, r#" SELECT id, organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id, suspended_at, created_at, updated_at FROM github_app_installations WHERE github_account_login = $1 "#, account_login ) .fetch_optional(self.pool) .await?; Ok(installation) } pub async fn get_by_organization( &self, organization_id: Uuid, ) -> Result, GitHubAppDbError> { let installation = sqlx::query_as!( GitHubAppInstallation, r#" SELECT id, organization_id, github_installation_id, github_account_login, github_account_type, repository_selection, installed_by_user_id, suspended_at, created_at, updated_at FROM github_app_installations WHERE organization_id = $1 "#, organization_id ) .fetch_optional(self.pool) .await?; Ok(installation) } pub async fn delete_by_github_id( &self, github_installation_id: i64, ) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" DELETE FROM github_app_installations WHERE github_installation_id = $1 "#, github_installation_id ) .execute(self.pool) .await?; Ok(()) } pub async fn delete_by_organization( &self, organization_id: Uuid, ) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" DELETE FROM github_app_installations WHERE organization_id = $1 "#, organization_id ) .execute(self.pool) .await?; Ok(()) } pub async fn suspend(&self, github_installation_id: i64) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" UPDATE github_app_installations SET suspended_at = NOW(), updated_at = NOW() WHERE github_installation_id = $1 "#, github_installation_id ) .execute(self.pool) .await?; Ok(()) } pub async fn unsuspend(&self, github_installation_id: i64) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" UPDATE github_app_installations SET suspended_at = NULL, updated_at = NOW() WHERE github_installation_id = $1 "#, github_installation_id ) .execute(self.pool) .await?; Ok(()) } pub async fn update_repository_selection( &self, github_installation_id: i64, repository_selection: &str, ) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" UPDATE github_app_installations SET repository_selection = $2, updated_at = NOW() WHERE github_installation_id = $1 "#, github_installation_id, repository_selection ) .execute(self.pool) .await?; Ok(()) } // ========== Repositories ========== pub async fn sync_repositories( &self, installation_id: Uuid, repos: &[(i64, String)], // (github_repo_id, repo_full_name) ) -> Result<(), GitHubAppDbError> { // Get current repo IDs to preserve review_enabled settings let current_repo_ids: Vec = repos.iter().map(|(id, _)| *id).collect(); // Delete repos that are no longer in the list sqlx::query!( r#" DELETE FROM github_app_repositories WHERE installation_id = $1 AND NOT (github_repo_id = ANY($2)) "#, installation_id, ¤t_repo_ids ) .execute(self.pool) .await?; // Upsert repos, preserving review_enabled for existing ones for (github_repo_id, repo_full_name) in repos { sqlx::query!( r#" INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name, review_enabled) VALUES ($1, $2, $3, true) ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET repo_full_name = EXCLUDED.repo_full_name "#, installation_id, github_repo_id, repo_full_name ) .execute(self.pool) .await?; } Ok(()) } pub async fn get_repositories( &self, installation_id: Uuid, ) -> Result, GitHubAppDbError> { let repos = sqlx::query_as!( GitHubAppRepository, r#" SELECT id, installation_id, github_repo_id, repo_full_name, review_enabled, created_at FROM github_app_repositories WHERE installation_id = $1 ORDER BY repo_full_name "#, installation_id ) .fetch_all(self.pool) .await?; Ok(repos) } pub async fn add_repositories( &self, installation_id: Uuid, repos: &[(i64, String)], ) -> Result<(), GitHubAppDbError> { for (github_repo_id, repo_full_name) in repos { sqlx::query!( r#" INSERT INTO github_app_repositories (installation_id, github_repo_id, repo_full_name) VALUES ($1, $2, $3) ON CONFLICT (installation_id, github_repo_id) DO UPDATE SET repo_full_name = EXCLUDED.repo_full_name "#, installation_id, github_repo_id, repo_full_name ) .execute(self.pool) .await?; } Ok(()) } pub async fn remove_repositories( &self, installation_id: Uuid, github_repo_ids: &[i64], ) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" DELETE FROM github_app_repositories WHERE installation_id = $1 AND github_repo_id = ANY($2) "#, installation_id, github_repo_ids ) .execute(self.pool) .await?; Ok(()) } /// Update the review_enabled flag for a repository pub async fn update_repository_review_enabled( &self, repo_id: Uuid, installation_id: Uuid, enabled: bool, ) -> Result { let repo = sqlx::query_as!( GitHubAppRepository, r#" UPDATE github_app_repositories SET review_enabled = $3 WHERE id = $1 AND installation_id = $2 RETURNING id, installation_id, github_repo_id, repo_full_name, review_enabled, created_at "#, repo_id, installation_id, enabled ) .fetch_optional(self.pool) .await? .ok_or(GitHubAppDbError::NotFound)?; Ok(repo) } /// Check if a repository has reviews enabled (for webhook filtering) pub async fn is_repository_review_enabled( &self, installation_id: Uuid, github_repo_id: i64, ) -> Result { let result = sqlx::query_scalar!( r#" SELECT review_enabled FROM github_app_repositories WHERE installation_id = $1 AND github_repo_id = $2 "#, installation_id, github_repo_id ) .fetch_optional(self.pool) .await?; // If repo not found, default to true (for "all repos" mode where repo might not be in DB yet) Ok(result.unwrap_or(true)) } /// Bulk update review_enabled for all repositories in an installation pub async fn set_all_repositories_review_enabled( &self, installation_id: Uuid, enabled: bool, ) -> Result { let result = sqlx::query!( r#" UPDATE github_app_repositories SET review_enabled = $2 WHERE installation_id = $1 "#, installation_id, enabled ) .execute(self.pool) .await?; Ok(result.rows_affected()) } // ========== Pending Installations ========== pub async fn create_pending( &self, organization_id: Uuid, user_id: Uuid, state_token: &str, expires_at: DateTime, ) -> Result { // Delete any existing pending installation for this org sqlx::query!( r#" DELETE FROM github_app_pending_installations WHERE organization_id = $1 "#, organization_id ) .execute(self.pool) .await?; let pending = sqlx::query_as!( PendingInstallation, r#" INSERT INTO github_app_pending_installations (organization_id, user_id, state_token, expires_at) VALUES ($1, $2, $3, $4) RETURNING id, organization_id, user_id, state_token, expires_at, created_at "#, organization_id, user_id, state_token, expires_at ) .fetch_one(self.pool) .await?; Ok(pending) } pub async fn get_pending_by_state( &self, state_token: &str, ) -> Result, GitHubAppDbError> { let pending = sqlx::query_as!( PendingInstallation, r#" SELECT id, organization_id, user_id, state_token, expires_at, created_at FROM github_app_pending_installations WHERE state_token = $1 AND expires_at > NOW() "#, state_token ) .fetch_optional(self.pool) .await?; Ok(pending) } pub async fn delete_pending(&self, state_token: &str) -> Result<(), GitHubAppDbError> { sqlx::query!( r#" DELETE FROM github_app_pending_installations WHERE state_token = $1 "#, state_token ) .execute(self.pool) .await?; Ok(()) } pub async fn cleanup_expired_pending(&self) -> Result { let result = sqlx::query!( r#" DELETE FROM github_app_pending_installations WHERE expires_at < NOW() "# ) .execute(self.pool) .await?; Ok(result.rows_affected()) } } ================================================ FILE: crates/remote/src/db/hosts.rs ================================================ use api_types::{RelayHost, RelaySession}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; use super::identity_errors::IdentityError; pub struct HostRepository<'a> { pool: &'a PgPool, } impl<'a> HostRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn assert_host_access( &self, host_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let row = sqlx::query!( r#" SELECT EXISTS ( SELECT 1 FROM hosts h LEFT JOIN organization_member_metadata om ON om.organization_id = h.shared_with_organization_id AND om.user_id = $2 WHERE h.id = $1 AND (h.owner_user_id = $2 OR om.user_id IS NOT NULL) ) AS "allowed!" "#, host_id, user_id ) .fetch_one(self.pool) .await?; if row.allowed { Ok(()) } else { Err(IdentityError::PermissionDenied) } } pub async fn create_session( &self, host_id: Uuid, request_user_id: Uuid, expires_at: DateTime, ) -> Result { sqlx::query_as!( RelaySession, r#" INSERT INTO relay_sessions (host_id, request_user_id, state, expires_at) VALUES ($1, $2, 'requested', $3) RETURNING id AS "id!: Uuid", host_id AS "host_id!: Uuid", request_user_id AS "request_user_id!: Uuid", state, created_at, expires_at, claimed_at, ended_at "#, host_id, request_user_id, expires_at ) .fetch_one(self.pool) .await } pub async fn list_accessible_hosts( &self, user_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as::<_, RelayHost>( r#" SELECT h.id, h.owner_user_id, h.name, h.status, h.last_seen_at, h.agent_version, h.created_at, h.updated_at, CASE WHEN h.owner_user_id = $1 THEN 'owner' ELSE 'member' END AS access_role FROM hosts h LEFT JOIN organization_member_metadata om ON om.organization_id = h.shared_with_organization_id AND om.user_id = $1 WHERE h.owner_user_id = $1 OR om.user_id IS NOT NULL ORDER BY h.updated_at DESC "#, ) .bind(user_id) .fetch_all(self.pool) .await } } ================================================ FILE: crates/remote/src/db/identity_errors.rs ================================================ use thiserror::Error; #[derive(Debug, Error)] pub enum IdentityError { #[error("identity record not found")] NotFound, #[error("permission denied: admin access required")] PermissionDenied, #[error("invitation error: {0}")] InvitationError(String), #[error("cannot delete organization: {0}")] CannotDeleteOrganization(String), #[error("organization conflict: {0}")] OrganizationConflict(String), #[error(transparent)] Database(#[from] sqlx::Error), #[cfg(feature = "vk-billing")] #[error("billing error: {0}")] Billing(crate::billing::BillingCheckError), } #[cfg(feature = "vk-billing")] impl From for IdentityError { fn from(err: crate::billing::BillingCheckError) -> Self { Self::Billing(err) } } #[cfg(not(feature = "vk-billing"))] impl From for IdentityError { fn from(err: crate::billing::BillingCheckError) -> Self { match err {} } } ================================================ FILE: crates/remote/src/db/invitations.rs ================================================ pub use api_types::InvitationStatus; use api_types::MemberRole; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use super::{ identity_errors::IdentityError, organization_members::{add_member, assert_admin}, organizations::{Organization, OrganizationRepository, is_personal_org}, }; use crate::{billing::BillingService, db::organization_members::is_member}; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Invitation { pub id: Uuid, pub organization_id: Uuid, pub invited_by_user_id: Option, pub email: String, pub role: MemberRole, pub status: InvitationStatus, pub token: String, pub expires_at: DateTime, pub created_at: DateTime, pub updated_at: DateTime, } pub struct InvitationRepository<'a> { pool: &'a PgPool, } impl<'a> InvitationRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn create_invitation( &self, organization_id: Uuid, invited_by_user_id: Uuid, email: &str, role: MemberRole, expires_at: DateTime, token: &str, ) -> Result { assert_admin(self.pool, organization_id, invited_by_user_id).await?; if OrganizationRepository::new(self.pool) .is_personal(organization_id) .await? { return Err(IdentityError::InvitationError( "Cannot invite members to a personal organization".to_string(), )); } let invitation = sqlx::query_as!( Invitation, r#" INSERT INTO organization_invitations ( organization_id, invited_by_user_id, email, role, token, expires_at ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id AS "id!", organization_id AS "organization_id!: Uuid", invited_by_user_id AS "invited_by_user_id?: Uuid", email AS "email!", role AS "role!: MemberRole", status AS "status!: InvitationStatus", token AS "token!", expires_at AS "expires_at!", created_at AS "created_at!", updated_at AS "updated_at!" "#, organization_id, invited_by_user_id, email, role as MemberRole, token, expires_at ) .fetch_one(self.pool) .await .map_err(|e| { if let Some(db_err) = e.as_database_error() && db_err.is_unique_violation() { return IdentityError::InvitationError( "A pending invitation already exists for this email".to_string(), ); } IdentityError::from(e) })?; Ok(invitation) } pub async fn list_invitations( &self, organization_id: Uuid, requesting_user_id: Uuid, ) -> Result, IdentityError> { assert_admin(self.pool, organization_id, requesting_user_id).await?; if OrganizationRepository::new(self.pool) .is_personal(organization_id) .await? { return Err(IdentityError::InvitationError( "Personal organizations do not support invitations".to_string(), )); } let invitations = sqlx::query_as!( Invitation, r#" SELECT id AS "id!", organization_id AS "organization_id!: Uuid", invited_by_user_id AS "invited_by_user_id?: Uuid", email AS "email!", role AS "role!: MemberRole", status AS "status!: InvitationStatus", token AS "token!", expires_at AS "expires_at!", created_at AS "created_at!", updated_at AS "updated_at!" FROM organization_invitations WHERE organization_id = $1 ORDER BY created_at DESC "#, organization_id ) .fetch_all(self.pool) .await?; Ok(invitations) } pub async fn get_invitation_by_token(&self, token: &str) -> Result { sqlx::query_as!( Invitation, r#" SELECT id AS "id!", organization_id AS "organization_id!: Uuid", invited_by_user_id AS "invited_by_user_id?: Uuid", email AS "email!", role AS "role!: MemberRole", status AS "status!: InvitationStatus", token AS "token!", expires_at AS "expires_at!", created_at AS "created_at!", updated_at AS "updated_at!" FROM organization_invitations WHERE token = $1 "#, token ) .fetch_optional(self.pool) .await? .ok_or(IdentityError::NotFound) } pub async fn revoke_invitation( &self, organization_id: Uuid, invitation_id: Uuid, requesting_user_id: Uuid, ) -> Result<(), IdentityError> { assert_admin(self.pool, organization_id, requesting_user_id).await?; let result = sqlx::query!( r#" DELETE FROM organization_invitations WHERE id = $1 AND organization_id = $2 "#, invitation_id, organization_id ) .execute(self.pool) .await?; if result.rows_affected() == 0 { return Err(IdentityError::NotFound); } Ok(()) } pub async fn accept_invitation( &self, token: &str, user_id: Uuid, billing: &BillingService, ) -> Result<(Organization, MemberRole), IdentityError> { let mut tx = super::begin_tx(self.pool).await?; let invitation = sqlx::query_as!( Invitation, r#" SELECT id AS "id!", organization_id AS "organization_id!: Uuid", invited_by_user_id AS "invited_by_user_id?: Uuid", email AS "email!", role AS "role!: MemberRole", status AS "status!: InvitationStatus", token AS "token!", expires_at AS "expires_at!", created_at AS "created_at!", updated_at AS "updated_at!" FROM organization_invitations WHERE token = $1 AND status = 'pending' FOR UPDATE "#, token ) .fetch_optional(&mut *tx) .await? .ok_or_else(|| { IdentityError::InvitationError("Invitation not found or already used".to_string()) })?; if is_personal_org(&mut *tx, invitation.organization_id).await? { tx.rollback().await?; return Err(IdentityError::InvitationError( "Cannot accept invitations for a personal organization".to_string(), )); } if invitation.expires_at < Utc::now() { sqlx::query!( r#" UPDATE organization_invitations SET status = 'expired' WHERE id = $1 "#, invitation.id ) .execute(&mut *tx) .await?; tx.commit().await?; return Err(IdentityError::InvitationError( "Invitation has expired".to_string(), )); } if is_member(&mut *tx, invitation.organization_id, user_id).await? { tx.rollback().await?; return Err(IdentityError::InvitationError( "You are already a member of the organization".to_string(), )); } billing.can_add_member(invitation.organization_id).await?; add_member( &mut *tx, invitation.organization_id, user_id, invitation.role, ) .await?; sqlx::query!( r#" UPDATE organization_invitations SET status = 'accepted' WHERE id = $1 "#, invitation.id ) .execute(&mut *tx) .await?; tx.commit().await?; billing .on_member_count_changed(invitation.organization_id) .await; let organization = OrganizationRepository::new(self.pool) .fetch_organization(invitation.organization_id) .await?; Ok((organization, invitation.role)) } } ================================================ FILE: crates/remote/src/db/issue_assignees.rs ================================================ use api_types::{DeleteResponse, IssueAssignee, MutationResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueAssigneeError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueAssigneeRepository; impl IssueAssigneeRepository { pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, IssueAssigneeError> { let record = sqlx::query_as!( IssueAssignee, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid", assigned_at AS "assigned_at!: DateTime" FROM issue_assignees WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueAssigneeError> { let records = sqlx::query_as!( IssueAssignee, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid", assigned_at AS "assigned_at!: DateTime" FROM issue_assignees WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, IssueAssigneeError> { let records = sqlx::query_as!( IssueAssignee, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid", assigned_at AS "assigned_at!: DateTime" FROM issue_assignees WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1) "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, issue_id: Uuid, user_id: Uuid, ) -> Result, IssueAssigneeError> { let id = id.unwrap_or_else(Uuid::new_v4); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueAssignee, r#" INSERT INTO issue_assignees (id, issue_id, user_id) VALUES ($1, $2, $3) RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid", assigned_at AS "assigned_at!: DateTime" "#, id, issue_id, user_id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_assignees WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } } ================================================ FILE: crates/remote/src/db/issue_comment_reactions.rs ================================================ use api_types::{DeleteResponse, IssueCommentReaction, MutationResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueCommentReactionError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueCommentReactionRepository; impl IssueCommentReactionRepository { pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, IssueCommentReactionError> { let record = sqlx::query_as!( IssueCommentReaction, r#" SELECT id AS "id!: Uuid", comment_id AS "comment_id!: Uuid", user_id AS "user_id!: Uuid", emoji AS "emoji!", created_at AS "created_at!: DateTime" FROM issue_comment_reactions WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueCommentReactionError> { let records = sqlx::query_as!( IssueCommentReaction, r#" SELECT id AS "id!: Uuid", comment_id AS "comment_id!: Uuid", user_id AS "user_id!: Uuid", emoji AS "emoji!", created_at AS "created_at!: DateTime" FROM issue_comment_reactions WHERE comment_id IN (SELECT id FROM issue_comments WHERE issue_id = $1) "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, comment_id: Uuid, user_id: Uuid, emoji: String, ) -> Result, IssueCommentReactionError> { let mut tx = super::begin_tx(pool).await?; let id = id.unwrap_or_else(Uuid::new_v4); let created_at = Utc::now(); let data = sqlx::query_as!( IssueCommentReaction, r#" INSERT INTO issue_comment_reactions (id, comment_id, user_id, emoji, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING id AS "id!: Uuid", comment_id AS "comment_id!: Uuid", user_id AS "user_id!: Uuid", emoji AS "emoji!", created_at AS "created_at!: DateTime" "#, id, comment_id, user_id, emoji, created_at ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Update an issue comment reaction with partial fields. Uses COALESCE to preserve existing values /// when None is provided. pub async fn update( pool: &PgPool, id: Uuid, emoji: Option, ) -> Result, IssueCommentReactionError> { let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueCommentReaction, r#" UPDATE issue_comment_reactions SET emoji = COALESCE($1, emoji) WHERE id = $2 RETURNING id AS "id!: Uuid", comment_id AS "comment_id!: Uuid", user_id AS "user_id!: Uuid", emoji AS "emoji!", created_at AS "created_at!: DateTime" "#, emoji, id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete( pool: &PgPool, id: Uuid, ) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_comment_reactions WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } pub async fn list_by_comment( pool: &PgPool, comment_id: Uuid, ) -> Result, IssueCommentReactionError> { let records = sqlx::query_as!( IssueCommentReaction, r#" SELECT id AS "id!: Uuid", comment_id AS "comment_id!: Uuid", user_id AS "user_id!: Uuid", emoji AS "emoji!", created_at AS "created_at!: DateTime" FROM issue_comment_reactions WHERE comment_id = $1 "#, comment_id ) .fetch_all(pool) .await?; Ok(records) } } ================================================ FILE: crates/remote/src/db/issue_comments.rs ================================================ use api_types::{DeleteResponse, IssueComment, MutationResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueCommentError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueCommentRepository; impl IssueCommentRepository { pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, IssueCommentError> { let record = sqlx::query_as!( IssueComment, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", author_id AS "author_id: Uuid", parent_id AS "parent_id: Uuid", message AS "message!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM issue_comments WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn create( pool: &PgPool, id: Option, issue_id: Uuid, author_id: Uuid, parent_id: Option, message: String, ) -> Result, IssueCommentError> { let id = id.unwrap_or_else(Uuid::new_v4); let now = Utc::now(); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueComment, r#" INSERT INTO issue_comments (id, issue_id, author_id, parent_id, message, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", author_id AS "author_id: Uuid", parent_id AS "parent_id: Uuid", message AS "message!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id, issue_id, author_id, parent_id, message, now, now ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Update an issue comment with partial fields. Uses COALESCE to preserve existing values /// when None is provided. pub async fn update( pool: &PgPool, id: Uuid, message: Option, ) -> Result, IssueCommentError> { let updated_at = Utc::now(); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueComment, r#" UPDATE issue_comments SET message = COALESCE($1, message), updated_at = $2 WHERE id = $3 RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", author_id AS "author_id: Uuid", parent_id AS "parent_id: Uuid", message AS "message!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, message, updated_at, id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_comments WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueCommentError> { let records = sqlx::query_as!( IssueComment, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", author_id AS "author_id: Uuid", parent_id AS "parent_id: Uuid", message AS "message!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM issue_comments WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } } ================================================ FILE: crates/remote/src/db/issue_followers.rs ================================================ use api_types::{DeleteResponse, IssueFollower, MutationResponse}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueFollowerError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueFollowerRepository; impl IssueFollowerRepository { pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, IssueFollowerError> { let record = sqlx::query_as!( IssueFollower, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid" FROM issue_followers WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueFollowerError> { let records = sqlx::query_as!( IssueFollower, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid" FROM issue_followers WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, IssueFollowerError> { let records = sqlx::query_as!( IssueFollower, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid" FROM issue_followers WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1) "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, issue_id: Uuid, user_id: Uuid, ) -> Result, IssueFollowerError> { let id = id.unwrap_or_else(Uuid::new_v4); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueFollower, r#" INSERT INTO issue_followers (id, issue_id, user_id) VALUES ($1, $2, $3) RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", user_id AS "user_id!: Uuid" "#, id, issue_id, user_id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_followers WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } } ================================================ FILE: crates/remote/src/db/issue_relationships.rs ================================================ use api_types::{DeleteResponse, IssueRelationship, IssueRelationshipType, MutationResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueRelationshipError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueRelationshipRepository; impl IssueRelationshipRepository { pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, IssueRelationshipError> { let record = sqlx::query_as!( IssueRelationship, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", related_issue_id AS "related_issue_id!: Uuid", relationship_type AS "relationship_type!: IssueRelationshipType", created_at AS "created_at!: DateTime" FROM issue_relationships WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueRelationshipError> { let records = sqlx::query_as!( IssueRelationship, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", related_issue_id AS "related_issue_id!: Uuid", relationship_type AS "relationship_type!: IssueRelationshipType", created_at AS "created_at!: DateTime" FROM issue_relationships WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, IssueRelationshipError> { let records = sqlx::query_as!( IssueRelationship, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", related_issue_id AS "related_issue_id!: Uuid", relationship_type AS "relationship_type!: IssueRelationshipType", created_at AS "created_at!: DateTime" FROM issue_relationships WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1) "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, issue_id: Uuid, related_issue_id: Uuid, relationship_type: IssueRelationshipType, ) -> Result, IssueRelationshipError> { let id = id.unwrap_or_else(Uuid::new_v4); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueRelationship, r#" INSERT INTO issue_relationships (id, issue_id, related_issue_id, relationship_type) VALUES ($1, $2, $3, $4) RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", related_issue_id AS "related_issue_id!: Uuid", relationship_type AS "relationship_type!: IssueRelationshipType", created_at AS "created_at!: DateTime" "#, id, issue_id, related_issue_id, relationship_type as IssueRelationshipType ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_relationships WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } } ================================================ FILE: crates/remote/src/db/issue_tags.rs ================================================ use api_types::{DeleteResponse, IssueTag, MutationResponse}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum IssueTagError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct IssueTagRepository; impl IssueTagRepository { pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, IssueTagError> { let record = sqlx::query_as!( IssueTag, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", tag_id AS "tag_id!: Uuid" FROM issue_tags WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueTagError> { let records = sqlx::query_as!( IssueTag, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", tag_id AS "tag_id!: Uuid" FROM issue_tags WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, IssueTagError> { let records = sqlx::query_as!( IssueTag, r#" SELECT id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", tag_id AS "tag_id!: Uuid" FROM issue_tags WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1) "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, id: Option, issue_id: Uuid, tag_id: Uuid, ) -> Result, IssueTagError> { let id = id.unwrap_or_else(Uuid::new_v4); let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( IssueTag, r#" INSERT INTO issue_tags (id, issue_id, tag_id) VALUES ($1, $2, $3) RETURNING id AS "id!: Uuid", issue_id AS "issue_id!: Uuid", tag_id AS "tag_id!: Uuid" "#, id, issue_id, tag_id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issue_tags WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } } ================================================ FILE: crates/remote/src/db/issues.rs ================================================ use api_types::{ DeleteResponse, Issue, IssuePriority, IssueSortField, ListIssuesResponse, MutationResponse, PullRequestStatus, SearchIssuesRequest, SortDirection, }; use chrono::{DateTime, Utc}; use serde_json::Value; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; use super::{ get_txid, issue_assignees::IssueAssigneeRepository, project_statuses::ProjectStatusRepository, pull_requests::PullRequestRepository, workspaces::WorkspaceRepository, }; #[derive(Debug, Error)] pub enum IssueError { #[error("database error: {0}")] Database(#[from] sqlx::Error), #[error("pull request error: {0}")] PullRequest(#[from] super::pull_requests::PullRequestError), #[error("project status error: {0}")] ProjectStatus(#[from] super::project_statuses::ProjectStatusError), #[error("workspace error: {0}")] Workspace(#[from] super::workspaces::WorkspaceError), #[error("issue assignee error: {0}")] IssueAssignee(#[from] super::issue_assignees::IssueAssigneeError), } pub struct IssueRepository; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IssueWorkflowSignal { ReviewStarted, WorkMerged, } impl IssueRepository { fn sort_field_key(sort_field: IssueSortField) -> &'static str { match sort_field { IssueSortField::SortOrder => "sort_order", IssueSortField::Priority => "priority", IssueSortField::CreatedAt => "created_at", IssueSortField::UpdatedAt => "updated_at", IssueSortField::Title => "title", } } fn sort_direction_key(sort_direction: SortDirection) -> &'static str { match sort_direction { SortDirection::Asc => "asc", SortDirection::Desc => "desc", } } fn escape_like_pattern(value: &str) -> String { value .replace('\\', r"\\") .replace('%', r"\%") .replace('_', r"\_") } pub async fn search( pool: &PgPool, query: &SearchIssuesRequest, ) -> Result { let status_ids = query.status_ids.as_deref(); let search_pattern = query .search .as_deref() .map(Self::escape_like_pattern) .map(|search| format!("%{search}%")); let simple_id = query.simple_id.as_deref().map(Self::escape_like_pattern); let tag_ids = query.tag_ids.as_deref(); let sort_field = Self::sort_field_key(query.sort_field.unwrap_or(IssueSortField::SortOrder)); let sort_direction = Self::sort_direction_key(query.sort_direction.unwrap_or(SortDirection::Asc)); let offset = query.offset.unwrap_or(0).max(0) as usize; let query_limit = query .limit .map(|value| value.max(0) as i64) .unwrap_or(i64::MAX); let total_count = sqlx::query_scalar!( r#" SELECT COUNT(*)::BIGINT FROM issues i WHERE i.project_id = $1 AND ($2::uuid IS NULL OR i.status_id = $2) AND ($3::uuid[] IS NULL OR i.status_id = ANY($3)) AND ($4::issue_priority IS NULL OR i.priority = $4) AND ($5::uuid IS NULL OR i.parent_issue_id = $5) AND ( $6::text IS NULL OR i.title ILIKE $6 ESCAPE '\' OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\' ) AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\') AND ( $8::uuid IS NULL OR EXISTS ( SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = $8 ) ) AND ( $9::uuid IS NULL OR EXISTS ( SELECT 1 FROM issue_tags it WHERE it.issue_id = i.id AND it.tag_id = $9 ) ) AND ( $10::uuid[] IS NULL OR EXISTS ( SELECT 1 FROM issue_tags it WHERE it.issue_id = i.id AND it.tag_id = ANY($10) ) ) "#, query.project_id, query.status_id, status_ids, query.priority as Option, query.parent_issue_id, search_pattern.as_deref(), simple_id.as_deref(), query.assignee_user_id, query.tag_id, tag_ids, ) .fetch_one(pool) .await? .unwrap_or(0) as usize; let issues = sqlx::query_as!( Issue, r#" SELECT i.id AS "id!: Uuid", i.project_id AS "project_id!: Uuid", i.issue_number AS "issue_number!", i.simple_id AS "simple_id!", i.status_id AS "status_id!: Uuid", i.title AS "title!", i.description AS "description?", i.priority AS "priority: IssuePriority", i.start_date AS "start_date?: DateTime", i.target_date AS "target_date?: DateTime", i.completed_at AS "completed_at?: DateTime", i.sort_order AS "sort_order!", i.parent_issue_id AS "parent_issue_id?: Uuid", i.parent_issue_sort_order AS "parent_issue_sort_order?", i.extension_metadata AS "extension_metadata!: Value", i.creator_user_id AS "creator_user_id?: Uuid", i.created_at AS "created_at!: DateTime", i.updated_at AS "updated_at!: DateTime" FROM issues i LEFT JOIN project_statuses ps ON ps.id = i.status_id WHERE i.project_id = $1 AND ($2::uuid IS NULL OR i.status_id = $2) AND ($3::uuid[] IS NULL OR i.status_id = ANY($3)) AND ($4::issue_priority IS NULL OR i.priority = $4) AND ($5::uuid IS NULL OR i.parent_issue_id = $5) AND ( $6::text IS NULL OR i.title ILIKE $6 ESCAPE '\' OR COALESCE(i.description, '') ILIKE $6 ESCAPE '\' ) AND ($7::text IS NULL OR i.simple_id ILIKE $7 ESCAPE '\') AND ( $8::uuid IS NULL OR EXISTS ( SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.user_id = $8 ) ) AND ( $9::uuid IS NULL OR EXISTS ( SELECT 1 FROM issue_tags it WHERE it.issue_id = i.id AND it.tag_id = $9 ) ) AND ( $10::uuid[] IS NULL OR EXISTS ( SELECT 1 FROM issue_tags it WHERE it.issue_id = i.id AND it.tag_id = ANY($10) ) ) ORDER BY CASE WHEN $11 = 'sort_order' AND $12 = 'asc' THEN ps.sort_order END ASC NULLS LAST, CASE WHEN $11 = 'sort_order' AND $12 = 'desc' THEN ps.sort_order END DESC NULLS LAST, CASE WHEN $11 = 'sort_order' AND $12 = 'asc' THEN i.sort_order END ASC NULLS LAST, CASE WHEN $11 = 'sort_order' AND $12 = 'desc' THEN i.sort_order END DESC NULLS LAST, CASE WHEN $11 = 'priority' AND $12 = 'asc' THEN i.priority END ASC NULLS LAST, CASE WHEN $11 = 'priority' AND $12 = 'desc' THEN i.priority END DESC NULLS FIRST, CASE WHEN $11 = 'created_at' AND $12 = 'asc' THEN i.created_at END ASC NULLS LAST, CASE WHEN $11 = 'created_at' AND $12 = 'desc' THEN i.created_at END DESC NULLS LAST, CASE WHEN $11 = 'updated_at' AND $12 = 'asc' THEN i.updated_at END ASC NULLS LAST, CASE WHEN $11 = 'updated_at' AND $12 = 'desc' THEN i.updated_at END DESC NULLS LAST, CASE WHEN $11 = 'title' AND $12 = 'asc' THEN i.title END ASC NULLS LAST, CASE WHEN $11 = 'title' AND $12 = 'desc' THEN i.title END DESC NULLS LAST, i.issue_number ASC LIMIT $13 OFFSET $14 "#, query.project_id, query.status_id, status_ids, query.priority as Option, query.parent_issue_id, search_pattern.as_deref(), simple_id.as_deref(), query.assignee_user_id, query.tag_id, tag_ids, sort_field, sort_direction, query_limit, offset as i64, ) .fetch_all(pool) .await?; let limit = query.limit.unwrap_or(issues.len() as i32).max(0) as usize; Ok(ListIssuesResponse { issues, total_count, limit, offset, }) } pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result, IssueError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( Issue, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", issue_number AS "issue_number!", simple_id AS "simple_id!", status_id AS "status_id!: Uuid", title AS "title!", description AS "description?", priority AS "priority: IssuePriority", start_date AS "start_date?: DateTime", target_date AS "target_date?: DateTime", completed_at AS "completed_at?: DateTime", sort_order AS "sort_order!", parent_issue_id AS "parent_issue_id?: Uuid", parent_issue_sort_order AS "parent_issue_sort_order?", extension_metadata AS "extension_metadata!: Value", creator_user_id AS "creator_user_id?: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM issues WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } pub async fn organization_id( pool: &PgPool, issue_id: Uuid, ) -> Result, IssueError> { let record = sqlx::query_scalar!( r#" SELECT p.organization_id FROM issues i INNER JOIN projects p ON p.id = i.project_id WHERE i.id = $1 "#, issue_id ) .fetch_optional(pool) .await?; Ok(record) } #[allow(clippy::too_many_arguments)] pub async fn create( pool: &PgPool, id: Option, project_id: Uuid, status_id: Uuid, title: String, description: Option, priority: Option, start_date: Option>, target_date: Option>, completed_at: Option>, sort_order: f64, parent_issue_id: Option, parent_issue_sort_order: Option, extension_metadata: Value, creator_user_id: Uuid, ) -> Result, IssueError> { let mut tx = super::begin_tx(pool).await?; let id = id.unwrap_or_else(Uuid::new_v4); // Note: issue_number and simple_id are auto-generated by the DB trigger let data = sqlx::query_as!( Issue, r#" INSERT INTO issues ( id, project_id, status_id, title, description, priority, start_date, target_date, completed_at, sort_order, parent_issue_id, parent_issue_sort_order, extension_metadata, creator_user_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", issue_number AS "issue_number!", simple_id AS "simple_id!", status_id AS "status_id!: Uuid", title AS "title!", description AS "description?", priority AS "priority: IssuePriority", start_date AS "start_date?: DateTime", target_date AS "target_date?: DateTime", completed_at AS "completed_at?: DateTime", sort_order AS "sort_order!", parent_issue_id AS "parent_issue_id?: Uuid", parent_issue_sort_order AS "parent_issue_sort_order?", extension_metadata AS "extension_metadata!: Value", creator_user_id AS "creator_user_id?: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id, project_id, status_id, title, description, priority as Option, start_date, target_date, completed_at, sort_order, parent_issue_id, parent_issue_sort_order, extension_metadata, creator_user_id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Update an issue with partial fields. /// /// For non-nullable fields, uses COALESCE to preserve existing values when None is provided. /// For nullable fields (Option>), uses CASE to distinguish between: /// - None: don't update the field /// - Some(None): set the field to NULL /// - Some(Some(value)): set the field to the value #[allow(clippy::too_many_arguments)] pub async fn update<'e, E>( executor: E, id: Uuid, status_id: Option, title: Option, description: Option>, priority: Option>, start_date: Option>>, target_date: Option>>, completed_at: Option>>, sort_order: Option, parent_issue_id: Option>, parent_issue_sort_order: Option>, extension_metadata: Option, ) -> Result where E: Executor<'e, Database = Postgres>, { // For nullable fields, extract boolean flags and flattened values // This preserves the distinction between "don't update" and "set to NULL" let update_description = description.is_some(); let description_value = description.flatten(); let update_priority = priority.is_some(); let priority_value = priority.flatten(); let update_start_date = start_date.is_some(); let start_date_value = start_date.flatten(); let update_target_date = target_date.is_some(); let target_date_value = target_date.flatten(); let update_completed_at = completed_at.is_some(); let completed_at_value = completed_at.flatten(); let update_parent_issue_id = parent_issue_id.is_some(); let parent_issue_id_value = parent_issue_id.flatten(); let update_parent_issue_sort_order = parent_issue_sort_order.is_some(); let parent_issue_sort_order_value = parent_issue_sort_order.flatten(); let data = sqlx::query_as!( Issue, r#" UPDATE issues SET status_id = COALESCE($1, status_id), title = COALESCE($2, title), description = CASE WHEN $3 THEN $4 ELSE description END, priority = CASE WHEN $5 THEN $6 ELSE priority END, start_date = CASE WHEN $7 THEN $8 ELSE start_date END, target_date = CASE WHEN $9 THEN $10 ELSE target_date END, completed_at = CASE WHEN $11 THEN $12 ELSE completed_at END, sort_order = COALESCE($13, sort_order), parent_issue_id = CASE WHEN $14 THEN $15 ELSE parent_issue_id END, parent_issue_sort_order = CASE WHEN $16 THEN $17 ELSE parent_issue_sort_order END, extension_metadata = COALESCE($18, extension_metadata), updated_at = NOW() WHERE id = $19 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", issue_number AS "issue_number!", simple_id AS "simple_id!", status_id AS "status_id!: Uuid", title AS "title!", description AS "description?", priority AS "priority: IssuePriority", start_date AS "start_date?: DateTime", target_date AS "target_date?: DateTime", completed_at AS "completed_at?: DateTime", sort_order AS "sort_order!", parent_issue_id AS "parent_issue_id?: Uuid", parent_issue_sort_order AS "parent_issue_sort_order?", extension_metadata AS "extension_metadata!: Value", creator_user_id AS "creator_user_id?: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, status_id, title, update_description, description_value, update_priority, priority_value as Option, update_start_date, start_date_value, update_target_date, target_date_value, update_completed_at, completed_at_value, sort_order, update_parent_issue_id, parent_issue_id_value, update_parent_issue_sort_order, parent_issue_sort_order_value, extension_metadata, id ) .fetch_one(executor) .await?; Ok(data) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM issues WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } /// Syncs issue status based on a workflow signal. /// - `ReviewStarted` → move issue to "In review" /// - `WorkMerged` → if all linked PRs are merged, move issue to "Done" async fn sync_status_from_workflow_signal( pool: &PgPool, issue_id: Uuid, signal: IssueWorkflowSignal, ) -> Result<(), IssueError> { let Some(issue) = Self::find_by_id(pool, issue_id).await? else { return Ok(()); }; let target_status_name = match signal { IssueWorkflowSignal::ReviewStarted => "In review", IssueWorkflowSignal::WorkMerged => { let prs = PullRequestRepository::list_by_issue(pool, issue_id).await?; let all_merged = prs.iter().all(|pr| pr.status == PullRequestStatus::Merged); if all_merged { "Done" } else { return Ok(()); } } }; let Some(target_status) = ProjectStatusRepository::find_by_name(pool, issue.project_id, target_status_name) .await? else { return Ok(()); }; if issue.status_id == target_status.id { return Ok(()); } Self::update( pool, issue_id, Some(target_status.id), None, None, None, None, None, None, None, None, None, None, ) .await?; Ok(()) } /// Syncs issue status based on the current pull-request status. /// - Open PR => move issue to "In review" /// - Merged/closed PR => if all linked PRs are merged, move issue to "Done" pub async fn sync_status_from_pull_request( pool: &PgPool, issue_id: Uuid, pr_status: PullRequestStatus, ) -> Result<(), IssueError> { let signal = if pr_status == PullRequestStatus::Open { IssueWorkflowSignal::ReviewStarted } else { IssueWorkflowSignal::WorkMerged }; Self::sync_status_from_workflow_signal(pool, issue_id, signal).await } /// Syncs issue status when a workspace is merged locally without a PR. pub async fn sync_status_from_local_workspace_merge( pool: &PgPool, issue_id: Uuid, ) -> Result<(), IssueError> { Self::sync_status_from_workflow_signal(pool, issue_id, IssueWorkflowSignal::WorkMerged) .await } /// Moves an issue to the given target status if its current status is "Backlog" or "To do". async fn move_to_status_if_pending( pool: &PgPool, issue_id: Uuid, current_status_id: Uuid, target_status_id: Uuid, ) -> Result<(), IssueError> { let Some(current_status) = ProjectStatusRepository::find_by_id(pool, current_status_id).await? else { return Ok(()); }; let name = current_status.name.to_lowercase(); if name == "backlog" || name == "to do" { Self::update( pool, issue_id, Some(target_status_id), None, None, None, None, None, None, None, None, None, None, ) .await?; } Ok(()) } /// Syncs issue state when a workspace is created: /// - If this is the first workspace and the issue is in "Backlog" or "To do", moves to "In progress" /// - If sub-issue, also moves parent issue to "In progress" if pending /// - If the issue has no assignees, adds the workspace creator as an assignee pub async fn sync_issue_from_workspace_created( pool: &PgPool, issue_id: Uuid, user_id: Uuid, ) -> Result<(), IssueError> { // Status sync: only on first workspace let workspace_count = WorkspaceRepository::count_by_issue_id(pool, issue_id).await?; if workspace_count == 1 { let Some(issue) = Self::find_by_id(pool, issue_id).await? else { return Ok(()); }; let Some(in_progress_status) = ProjectStatusRepository::find_by_name(pool, issue.project_id, "In progress") .await? else { return Ok(()); }; Self::move_to_status_if_pending(pool, issue_id, issue.status_id, in_progress_status.id) .await?; // If sub-issue, also move parent issue to "In progress" if let Some(parent_issue_id) = issue.parent_issue_id && let Some(parent_issue) = Self::find_by_id(pool, parent_issue_id).await? { Self::move_to_status_if_pending( pool, parent_issue_id, parent_issue.status_id, in_progress_status.id, ) .await?; } } // Assignee sync: add creator if no assignees exist let assignees = IssueAssigneeRepository::list_by_issue(pool, issue_id).await?; if assignees.is_empty() { IssueAssigneeRepository::create(pool, None, issue_id, user_id).await?; } Ok(()) } } #[cfg(test)] mod tests { use super::IssueRepository; #[test] fn escapes_like_pattern_special_characters() { assert_eq!( IssueRepository::escape_like_pattern(r"100%_done\ish"), r"100\%\_done\\ish" ); } } ================================================ FILE: crates/remote/src/db/migration.rs ================================================ use api_types::{ MigrateIssueRequest, MigrateProjectRequest, MigratePullRequestRequest, MigrateWorkspaceRequest, PullRequestStatus, }; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use super::{project_statuses::ProjectStatusRepository, tags::TagRepository}; #[derive(Debug, Error)] pub enum MigrationError { #[error(transparent)] Database(#[from] sqlx::Error), #[error("project status error: {0}")] ProjectStatus(#[from] super::project_statuses::ProjectStatusError), #[error("tag error: {0}")] Tag(#[from] super::tags::TagError), } pub struct MigrationRepository; impl MigrationRepository { pub async fn bulk_create_projects( pool: &PgPool, inputs: Vec, ) -> Result, MigrationError> { if inputs.is_empty() { return Ok(vec![]); } let mut tx = super::begin_tx(pool).await?; let org_ids: Vec = inputs.iter().map(|i| i.organization_id).collect(); let names: Vec = inputs.iter().map(|i| i.name.clone()).collect(); let colors: Vec = inputs.iter().map(|i| i.color.clone()).collect(); let created_ats: Vec> = inputs.iter().map(|i| i.created_at).collect(); let ids = sqlx::query_scalar!( r#" INSERT INTO projects (id, organization_id, name, color, created_at, updated_at) SELECT gen_random_uuid(), organization_id, name, color, created_at, NOW() FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::timestamptz[]) AS t(organization_id, name, color, created_at) RETURNING id "#, &org_ids, &names, &colors, &created_ats, ) .fetch_all(&mut *tx) .await?; for id in &ids { TagRepository::create_default_tags(&mut *tx, *id).await?; ProjectStatusRepository::create_default_statuses(&mut *tx, *id).await?; } tx.commit().await?; Ok(ids) } pub async fn bulk_create_issues( pool: &PgPool, inputs: Vec, ) -> Result, MigrationError> { if inputs.is_empty() { return Ok(vec![]); } let project_ids: Vec = inputs.iter().map(|i| i.project_id).collect(); let status_names: Vec = inputs.iter().map(|i| i.status_name.clone()).collect(); let titles: Vec = inputs.iter().map(|i| i.title.clone()).collect(); let descriptions: Vec> = inputs.iter().map(|i| i.description.clone()).collect(); let created_ats: Vec> = inputs.iter().map(|i| i.created_at).collect(); let ids = sqlx::query_scalar!( r#" INSERT INTO issues (id, project_id, status_id, title, description, priority, sort_order, extension_metadata, created_at) SELECT gen_random_uuid(), t.project_id, (SELECT id FROM project_statuses ps WHERE ps.project_id = t.project_id AND LOWER(ps.name) = LOWER(t.status_name)), t.title, t.description, NULL, 0.0, '{}'::jsonb, t.created_at FROM UNNEST($1::uuid[], $2::text[], $3::text[], $4::text[], $5::timestamptz[]) AS t(project_id, status_name, title, description, created_at) RETURNING id "#, &project_ids, &status_names, &titles, &descriptions as &[Option], &created_ats ) .fetch_all(pool) .await?; Ok(ids) } pub async fn bulk_create_pull_requests( pool: &PgPool, inputs: Vec, ) -> Result, MigrationError> { if inputs.is_empty() { return Ok(vec![]); } let urls: Vec = inputs.iter().map(|i| i.url.clone()).collect(); let numbers: Vec = inputs.iter().map(|i| i.number).collect(); let statuses: Vec = inputs.iter().map(|i| parse_pr_status(&i.status)).collect(); let merged_ats: Vec>> = inputs.iter().map(|i| i.merged_at).collect(); let merge_commit_shas: Vec> = inputs.iter().map(|i| i.merge_commit_sha.clone()).collect(); let target_branch_names: Vec = inputs .iter() .map(|i| i.target_branch_name.clone()) .collect(); let issue_ids: Vec = inputs.iter().map(|i| i.issue_id).collect(); let ids = sqlx::query_scalar!( r#" INSERT INTO pull_requests (id, url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id) SELECT gen_random_uuid(), url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id FROM UNNEST($1::text[], $2::int[], $3::pull_request_status[], $4::timestamptz[], $5::text[], $6::text[], $7::uuid[]) AS t(url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id) RETURNING id "#, &urls, &numbers, &statuses as &[PullRequestStatus], &merged_ats as &[Option>], &merge_commit_shas as &[Option], &target_branch_names, &issue_ids ) .fetch_all(pool) .await?; Ok(ids) } pub async fn bulk_create_workspaces( pool: &PgPool, owner_user_id: Uuid, inputs: Vec, ) -> Result, MigrationError> { if inputs.is_empty() { return Ok(vec![]); } let project_ids: Vec = inputs.iter().map(|i| i.project_id).collect(); let issue_ids: Vec> = inputs.iter().map(|i| i.issue_id).collect(); let local_workspace_ids: Vec = inputs.iter().map(|i| i.local_workspace_id).collect(); let archived_values: Vec = inputs.iter().map(|i| i.archived).collect(); let created_ats: Vec> = inputs.iter().map(|i| i.created_at).collect(); let owner_ids: Vec = vec![owner_user_id; inputs.len()]; let ids = sqlx::query_scalar!( r#" INSERT INTO workspaces (project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at) SELECT project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[], $4::uuid[], $5::boolean[], $6::timestamptz[]) AS t(project_id, owner_user_id, issue_id, local_workspace_id, archived, created_at) RETURNING id "#, &project_ids, &owner_ids, &issue_ids as &[Option], &local_workspace_ids, &archived_values, &created_ats, ) .fetch_all(pool) .await?; Ok(ids) } } fn parse_pr_status(s: &str) -> PullRequestStatus { match s.to_lowercase().as_str() { "merged" => PullRequestStatus::Merged, "closed" => PullRequestStatus::Closed, _ => PullRequestStatus::Open, } } ================================================ FILE: crates/remote/src/db/mod.rs ================================================ pub mod attachments; pub mod auth; pub mod blobs; pub mod digest; pub mod electric_publications; pub mod github_app; pub mod hosts; pub mod identity_errors; pub mod invitations; pub mod issue_assignees; pub mod issue_comment_reactions; pub mod issue_comments; pub mod issue_followers; pub mod issue_relationships; pub mod issue_tags; pub mod issues; pub mod migration; pub mod notifications; pub mod oauth; pub mod oauth_accounts; pub mod organization_members; pub mod organizations; pub mod pending_uploads; pub mod project_notification_preferences; pub mod project_statuses; pub mod projects; pub mod pull_requests; pub mod reviews; pub mod tags; pub mod types; pub mod users; pub mod workspaces; use sqlx::{ Executor, PgPool, Postgres, Transaction, migrate::MigrateError, postgres::{PgConnectOptions, PgPoolOptions}, }; use uuid::Uuid; pub(crate) type Tx<'a> = Transaction<'a, Postgres>; /// Per-request context propagated to database transactions via a tokio task-local. /// The auth middleware initialises the scope; `begin_tx` reads it. #[derive(Clone)] pub struct TxContext { pub user_id: Uuid, pub request_id: String, } tokio::task_local! { pub static TX_CONTEXT: Option; } /// Begin a transaction and tag it with the current request's request ID. /// If no context is set (e.g. background jobs), the transaction is untagged. pub async fn begin_tx(pool: &PgPool) -> Result, sqlx::Error> { let mut tx = pool.begin().await?; let ctx = TX_CONTEXT.try_with(|c| c.clone()).ok().flatten(); if let Some(ctx) = ctx { let name = format!("vk r:{}", ctx.request_id.replace('-', "")); sqlx::query("SELECT set_config('application_name', $1, true)") .bind(&name) .execute(&mut *tx) .await?; } Ok(tx) } /// Get the current transaction ID from Postgres. /// Must be called within an active transaction. /// Uses text conversion to avoid xid8->bigint cast issues in some PG versions. pub async fn get_txid<'e, E>(executor: E) -> Result where E: Executor<'e, Database = Postgres>, { let row: (i64,) = sqlx::query_as("SELECT pg_current_xact_id()::text::bigint") .fetch_one(executor) .await?; Ok(row.0) } pub(crate) async fn migrate(pool: &PgPool) -> Result<(), MigrateError> { sqlx::migrate!("./migrations").run(pool).await } pub async fn create_pool(database_url: &str) -> Result { let options: PgConnectOptions = database_url .parse::()? .application_name("vibe-kanban-remote"); PgPoolOptions::new() .max_connections(10) .connect_with(options) .await } pub(crate) async fn ensure_electric_role_password( pool: &PgPool, password: &str, ) -> Result<(), sqlx::Error> { if password.is_empty() { return Ok(()); } // PostgreSQL doesn't support parameter binding for ALTER ROLE PASSWORD // We need to escape the password properly and embed it directly in the SQL let escaped_password = password.replace("'", "''"); let sql = format!("ALTER ROLE electric_sync WITH PASSWORD '{escaped_password}'"); sqlx::query(&sql).execute(pool).await?; Ok(()) } ================================================ FILE: crates/remote/src/db/notifications.rs ================================================ use api_types::{Notification, NotificationPayload, NotificationType}; use chrono::{DateTime, Utc}; use sqlx::{Executor, FromRow, Postgres}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum NotificationError { #[error(transparent)] Database(#[from] sqlx::Error), } #[derive(Debug, FromRow)] struct NotificationRow { id: Uuid, organization_id: Uuid, user_id: Uuid, notification_type: NotificationType, payload: sqlx::types::Json, issue_id: Option, comment_id: Option, seen: bool, dismissed_at: Option>, created_at: DateTime, } impl From for Notification { fn from(row: NotificationRow) -> Self { Self { id: row.id, organization_id: row.organization_id, user_id: row.user_id, notification_type: row.notification_type, payload: row.payload.0, issue_id: row.issue_id, comment_id: row.comment_id, seen: row.seen, dismissed_at: row.dismissed_at, created_at: row.created_at, } } } pub struct NotificationRepository; impl NotificationRepository { pub async fn find_by_id<'e, E>( executor: E, id: Uuid, ) -> Result, NotificationError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( NotificationRow, r#" SELECT id, organization_id, user_id, notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen, dismissed_at, created_at FROM notifications WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record.map(Into::into)) } pub async fn create<'e, E>( executor: E, organization_id: Uuid, user_id: Uuid, notification_type: NotificationType, payload: NotificationPayload, issue_id: Option, comment_id: Option, ) -> Result where E: Executor<'e, Database = Postgres>, { let id = Uuid::new_v4(); let now = Utc::now(); let payload = sqlx::types::Json(payload); let record = sqlx::query_as!( NotificationRow, r#" INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, organization_id, user_id, notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen, dismissed_at, created_at "#, id, organization_id, user_id, notification_type as NotificationType, payload as sqlx::types::Json, issue_id, comment_id, now ) .fetch_one(executor) .await?; Ok(record.into()) } pub async fn list_by_user<'e, E>( executor: E, user_id: Uuid, include_dismissed: bool, ) -> Result, NotificationError> where E: Executor<'e, Database = Postgres>, { let records = if include_dismissed { sqlx::query_as!( NotificationRow, r#" SELECT id, organization_id, user_id, notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen, dismissed_at, created_at FROM notifications WHERE user_id = $1 ORDER BY created_at DESC "#, user_id ) .fetch_all(executor) .await? } else { sqlx::query_as!( NotificationRow, r#" SELECT id, organization_id, user_id, notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen, dismissed_at, created_at FROM notifications WHERE user_id = $1 AND dismissed_at IS NULL ORDER BY created_at DESC "#, user_id ) .fetch_all(executor) .await? }; Ok(records.into_iter().map(Into::into).collect()) } pub async fn update<'e, E>( executor: E, id: Uuid, seen: Option, ) -> Result where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( NotificationRow, r#" UPDATE notifications SET seen = COALESCE($1, seen), dismissed_at = CASE WHEN $1 = true AND dismissed_at IS NULL THEN NOW() ELSE dismissed_at END WHERE id = $2 RETURNING id, organization_id, user_id, notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen, dismissed_at, created_at "#, seen, id ) .fetch_one(executor) .await?; Ok(record.into()) } pub async fn upsert_recent<'e, E>( executor: E, organization_id: Uuid, user_id: Uuid, notification_type: NotificationType, payload: NotificationPayload, issue_id: Option, comment_id: Option, ) -> Result where E: Executor<'e, Database = Postgres>, { let id = Uuid::new_v4(); let now = Utc::now(); let payload = sqlx::types::Json(payload); let record: NotificationRow = sqlx::query_as!( NotificationRow, r#" WITH existing AS ( SELECT id FROM notifications WHERE user_id = $3 AND notification_type = $4 AND issue_id IS NOT DISTINCT FROM $6 AND comment_id IS NOT DISTINCT FROM $7 AND created_at > NOW() - INTERVAL '1 minute' ORDER BY created_at DESC LIMIT 1 ), updated AS ( UPDATE notifications SET payload = $5, seen = FALSE, dismissed_at = NULL, created_at = $8 WHERE id = (SELECT id FROM existing) RETURNING id, organization_id, user_id, notification_type, payload, issue_id, comment_id, seen, dismissed_at, created_at ), inserted AS ( INSERT INTO notifications (id, organization_id, user_id, notification_type, payload, issue_id, comment_id, created_at) SELECT $1, $2, $3, $4, $5, $6, $7, $8 WHERE NOT EXISTS (SELECT 1 FROM existing) RETURNING id, organization_id, user_id, notification_type, payload, issue_id, comment_id, seen, dismissed_at, created_at ) SELECT id as "id!", organization_id as "organization_id!", user_id as "user_id!", notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen as "seen!", dismissed_at, created_at as "created_at!" FROM updated UNION ALL SELECT id as "id!", organization_id as "organization_id!", user_id as "user_id!", notification_type as "notification_type!: NotificationType", payload as "payload!: sqlx::types::Json", issue_id, comment_id, seen as "seen!", dismissed_at, created_at as "created_at!" FROM inserted "#, id, organization_id, user_id, notification_type as NotificationType, payload as sqlx::types::Json, issue_id, comment_id, now ) .fetch_one(executor) .await?; Ok(record.into()) } pub async fn delete<'e, E>(executor: E, id: Uuid) -> Result<(), NotificationError> where E: Executor<'e, Database = Postgres>, { sqlx::query!("DELETE FROM notifications WHERE id = $1", id) .execute(executor) .await?; Ok(()) } } ================================================ FILE: crates/remote/src/db/oauth.rs ================================================ use std::str::FromStr; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthorizationStatus { Pending, Authorized, Redeemed, Error, Expired, } impl AuthorizationStatus { pub fn as_str(&self) -> &'static str { match self { Self::Pending => "pending", Self::Authorized => "authorized", Self::Redeemed => "redeemed", Self::Error => "error", Self::Expired => "expired", } } } impl FromStr for AuthorizationStatus { type Err = (); fn from_str(input: &str) -> Result { match input { "pending" => Ok(Self::Pending), "authorized" => Ok(Self::Authorized), "redeemed" => Ok(Self::Redeemed), "error" => Ok(Self::Error), "expired" => Ok(Self::Expired), _ => Err(()), } } } #[derive(Debug, Error)] pub enum OAuthHandoffError { #[error("oauth handoff not found")] NotFound, #[error("oauth handoff is not authorized")] NotAuthorized, #[error("oauth handoff already redeemed or not in authorized state")] AlreadyRedeemed, #[error(transparent)] Database(#[from] sqlx::Error), } #[derive(Debug, Clone, sqlx::FromRow)] pub struct OAuthHandoff { pub id: Uuid, pub provider: String, pub state: String, pub return_to: String, pub app_challenge: String, pub app_code_hash: Option, pub status: String, pub error_code: Option, pub expires_at: DateTime, pub authorized_at: Option>, pub redeemed_at: Option>, pub user_id: Option, pub session_id: Option, pub encrypted_provider_tokens: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl OAuthHandoff { pub fn status(&self) -> Option { AuthorizationStatus::from_str(&self.status).ok() } } #[derive(Debug, Clone)] pub struct CreateOAuthHandoff<'a> { pub provider: &'a str, pub state: &'a str, pub return_to: &'a str, pub app_challenge: &'a str, pub expires_at: DateTime, } pub struct OAuthHandoffRepository<'a> { pool: &'a PgPool, } impl<'a> OAuthHandoffRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn create( &self, data: CreateOAuthHandoff<'_>, ) -> Result { sqlx::query_as!( OAuthHandoff, r#" INSERT INTO oauth_handoffs ( provider, state, return_to, app_challenge, expires_at ) VALUES ($1, $2, $3, $4, $5) RETURNING id AS "id!", provider AS "provider!", state AS "state!", return_to AS "return_to!", app_challenge AS "app_challenge!", app_code_hash AS "app_code_hash?", status AS "status!", error_code AS "error_code?", expires_at AS "expires_at!", authorized_at AS "authorized_at?", redeemed_at AS "redeemed_at?", user_id AS "user_id?", session_id AS "session_id?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" "#, data.provider, data.state, data.return_to, data.app_challenge, data.expires_at, ) .fetch_one(self.pool) .await .map_err(OAuthHandoffError::from) } pub async fn get(&self, id: Uuid) -> Result { sqlx::query_as!( OAuthHandoff, r#" SELECT id AS "id!", provider AS "provider!", state AS "state!", return_to AS "return_to!", app_challenge AS "app_challenge!", app_code_hash AS "app_code_hash?", status AS "status!", error_code AS "error_code?", expires_at AS "expires_at!", authorized_at AS "authorized_at?", redeemed_at AS "redeemed_at?", user_id AS "user_id?", session_id AS "session_id?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" FROM oauth_handoffs WHERE id = $1 "#, id ) .fetch_optional(self.pool) .await? .ok_or(OAuthHandoffError::NotFound) } pub async fn get_by_state(&self, state: &str) -> Result { sqlx::query_as!( OAuthHandoff, r#" SELECT id AS "id!", provider AS "provider!", state AS "state!", return_to AS "return_to!", app_challenge AS "app_challenge!", app_code_hash AS "app_code_hash?", status AS "status!", error_code AS "error_code?", expires_at AS "expires_at!", authorized_at AS "authorized_at?", redeemed_at AS "redeemed_at?", user_id AS "user_id?", session_id AS "session_id?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" FROM oauth_handoffs WHERE state = $1 "#, state ) .fetch_optional(self.pool) .await? .ok_or(OAuthHandoffError::NotFound) } pub async fn set_status( &self, id: Uuid, status: AuthorizationStatus, error_code: Option<&str>, ) -> Result<(), OAuthHandoffError> { sqlx::query!( r#" UPDATE oauth_handoffs SET status = $2, error_code = $3 WHERE id = $1 "#, id, status.as_str(), error_code ) .execute(self.pool) .await?; Ok(()) } pub async fn mark_authorized( &self, id: Uuid, user_id: Uuid, session_id: Uuid, app_code_hash: &str, encrypted_provider_tokens: Option, ) -> Result<(), OAuthHandoffError> { sqlx::query!( r#" UPDATE oauth_handoffs SET status = 'authorized', error_code = NULL, user_id = $2, session_id = $3, app_code_hash = $4, encrypted_provider_tokens = $5, authorized_at = NOW() WHERE id = $1 "#, id, user_id, session_id, app_code_hash, encrypted_provider_tokens ) .execute(self.pool) .await?; Ok(()) } pub async fn mark_redeemed(&self, id: Uuid) -> Result<(), OAuthHandoffError> { let result = sqlx::query!( r#" UPDATE oauth_handoffs SET status = 'redeemed', encrypted_provider_tokens = NULL, redeemed_at = NOW() WHERE id = $1 AND status = 'authorized' "#, id ) .execute(self.pool) .await?; if result.rows_affected() == 0 { return Err(OAuthHandoffError::AlreadyRedeemed); } Ok(()) } pub async fn ensure_redeemable(&self, id: Uuid) -> Result<(), OAuthHandoffError> { let handoff = self.get(id).await?; match handoff.status() { Some(AuthorizationStatus::Authorized) => Ok(()), Some(AuthorizationStatus::Pending) => Err(OAuthHandoffError::NotAuthorized), _ => Err(OAuthHandoffError::AlreadyRedeemed), } } } ================================================ FILE: crates/remote/src/db/oauth_accounts.rs ================================================ use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum OAuthAccountError { #[error(transparent)] Database(#[from] sqlx::Error), } #[derive(Debug, Clone, sqlx::FromRow)] pub struct OAuthAccount { pub id: Uuid, pub user_id: Uuid, pub provider: String, pub provider_user_id: String, pub email: Option, pub username: Option, pub display_name: Option, pub avatar_url: Option, pub encrypted_provider_tokens: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone)] pub struct OAuthAccountInsert<'a> { pub user_id: Uuid, pub provider: &'a str, pub provider_user_id: &'a str, pub email: Option<&'a str>, pub username: Option<&'a str>, pub display_name: Option<&'a str>, pub avatar_url: Option<&'a str>, pub encrypted_provider_tokens: Option<&'a str>, } pub struct OAuthAccountRepository<'a> { pool: &'a PgPool, } impl<'a> OAuthAccountRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn get_by_provider_user( &self, provider: &str, provider_user_id: &str, ) -> Result, OAuthAccountError> { sqlx::query_as!( OAuthAccount, r#" SELECT id AS "id!: Uuid", user_id AS "user_id!: Uuid", provider AS "provider!", provider_user_id AS "provider_user_id!", email AS "email?", username AS "username?", display_name AS "display_name?", avatar_url AS "avatar_url?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" FROM oauth_accounts WHERE provider = $1 AND provider_user_id = $2 "#, provider, provider_user_id ) .fetch_optional(self.pool) .await .map_err(OAuthAccountError::from) } pub async fn get_by_user_provider( &self, user_id: Uuid, provider: &str, ) -> Result, OAuthAccountError> { sqlx::query_as!( OAuthAccount, r#" SELECT id AS "id!: Uuid", user_id AS "user_id!: Uuid", provider AS "provider!", provider_user_id AS "provider_user_id!", email AS "email?", username AS "username?", display_name AS "display_name?", avatar_url AS "avatar_url?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" FROM oauth_accounts WHERE user_id = $1 AND provider = $2 LIMIT 1 "#, user_id, provider, ) .fetch_optional(self.pool) .await .map_err(OAuthAccountError::from) } pub async fn list_by_user( &self, user_id: Uuid, ) -> Result, OAuthAccountError> { sqlx::query_as!( OAuthAccount, r#" SELECT id AS "id!: Uuid", user_id AS "user_id!: Uuid", provider AS "provider!", provider_user_id AS "provider_user_id!", email AS "email?", username AS "username?", display_name AS "display_name?", avatar_url AS "avatar_url?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" FROM oauth_accounts WHERE user_id = $1 ORDER BY provider "#, user_id ) .fetch_all(self.pool) .await .map_err(OAuthAccountError::from) } pub async fn upsert( &self, account: OAuthAccountInsert<'_>, ) -> Result { sqlx::query_as!( OAuthAccount, r#" INSERT INTO oauth_accounts ( user_id, provider, provider_user_id, email, username, display_name, avatar_url, encrypted_provider_tokens ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (provider, provider_user_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, display_name = EXCLUDED.display_name, avatar_url = EXCLUDED.avatar_url, encrypted_provider_tokens = COALESCE( EXCLUDED.encrypted_provider_tokens, oauth_accounts.encrypted_provider_tokens ) RETURNING id AS "id!: Uuid", user_id AS "user_id!: Uuid", provider AS "provider!", provider_user_id AS "provider_user_id!", email AS "email?", username AS "username?", display_name AS "display_name?", avatar_url AS "avatar_url?", encrypted_provider_tokens AS "encrypted_provider_tokens?", created_at AS "created_at!", updated_at AS "updated_at!" "#, account.user_id, account.provider, account.provider_user_id, account.email, account.username, account.display_name, account.avatar_url, account.encrypted_provider_tokens ) .fetch_one(self.pool) .await .map_err(OAuthAccountError::from) } pub async fn update_encrypted_provider_tokens( &self, user_id: Uuid, provider: &str, encrypted_provider_tokens: &str, ) -> Result<(), OAuthAccountError> { sqlx::query!( r#" UPDATE oauth_accounts SET encrypted_provider_tokens = $3 WHERE user_id = $1 AND provider = $2 "#, user_id, provider, encrypted_provider_tokens, ) .execute(self.pool) .await?; Ok(()) } } ================================================ FILE: crates/remote/src/db/organization_members.rs ================================================ use api_types::MemberRole; use sqlx::{Executor, PgPool, Postgres}; use uuid::Uuid; use super::identity_errors::IdentityError; pub(super) async fn add_member<'a, E>( executor: E, organization_id: Uuid, user_id: Uuid, role: MemberRole, ) -> Result<(), sqlx::Error> where E: Executor<'a, Database = Postgres>, { sqlx::query!( r#" INSERT INTO organization_member_metadata (organization_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (organization_id, user_id) DO UPDATE SET role = EXCLUDED.role "#, organization_id, user_id, role as MemberRole ) .execute(executor) .await?; Ok(()) } pub(crate) async fn check_user_role( pool: &PgPool, organization_id: Uuid, user_id: Uuid, ) -> Result, IdentityError> { let result = sqlx::query!( r#" SELECT role AS "role!: MemberRole" FROM organization_member_metadata WHERE organization_id = $1 AND user_id = $2 "#, organization_id, user_id ) .fetch_optional(pool) .await?; Ok(result.map(|r| r.role)) } pub async fn is_member<'a, E>( executor: E, organization_id: Uuid, user_id: Uuid, ) -> Result where E: Executor<'a, Database = Postgres>, { let exists = sqlx::query_scalar!( r#" SELECT EXISTS( SELECT 1 FROM organization_member_metadata WHERE organization_id = $1 AND user_id = $2 ) AS "exists!" "#, organization_id, user_id ) .fetch_one(executor) .await?; Ok(exists) } pub(crate) async fn assert_membership( pool: &PgPool, organization_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let exists = is_member(pool, organization_id, user_id).await?; if exists { Ok(()) } else { Err(IdentityError::NotFound) } } pub(crate) async fn assert_issue_access( pool: &PgPool, issue_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let org_id = sqlx::query_scalar!( r#" SELECT p.organization_id FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.id = $1 "#, issue_id ) .fetch_optional(pool) .await? .ok_or(IdentityError::NotFound)?; assert_membership(pool, org_id, user_id).await } pub(crate) async fn assert_project_access( pool: &PgPool, project_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let org_id = sqlx::query_scalar!( r#"SELECT organization_id FROM projects WHERE id = $1"#, project_id ) .fetch_optional(pool) .await? .ok_or(IdentityError::NotFound)?; assert_membership(pool, org_id, user_id).await } pub(crate) async fn list_by_organization( pool: &PgPool, organization_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( api_types::OrganizationMember, r#" SELECT organization_id AS "organization_id!: Uuid", user_id AS "user_id!: Uuid", role AS "role!: MemberRole", joined_at AS "joined_at!", last_seen_at FROM organization_member_metadata WHERE organization_id = $1 "#, organization_id ) .fetch_all(pool) .await } pub(crate) async fn list_users_by_organization( pool: &PgPool, organization_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( api_types::User, r#" SELECT id AS "id!: Uuid", email AS "email!", first_name AS "first_name?", last_name AS "last_name?", username AS "username?", created_at AS "created_at!", updated_at AS "updated_at!" FROM users WHERE id IN (SELECT user_id FROM organization_member_metadata WHERE organization_id = $1) "#, organization_id ) .fetch_all(pool) .await } pub(super) async fn assert_admin( pool: &PgPool, organization_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { let role = check_user_role(pool, organization_id, user_id).await?; match role { Some(MemberRole::Admin) => Ok(()), _ => Err(IdentityError::PermissionDenied), } } ================================================ FILE: crates/remote/src/db/organizations.rs ================================================ pub use api_types::{MemberRole, Organization, OrganizationWithRole}; use sqlx::{Executor, PgPool, Postgres, query_as}; use uuid::Uuid; use super::{ identity_errors::IdentityError, organization_members::{ add_member, assert_admin as check_admin, assert_membership as check_membership, check_user_role as get_user_role, }, projects::ProjectRepository, }; pub struct OrganizationRepository<'a> { pool: &'a PgPool, } impl<'a> OrganizationRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn assert_membership( &self, organization_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { check_membership(self.pool, organization_id, user_id).await } pub async fn fetch_organization( &self, organization_id: Uuid, ) -> Result { query_as!( Organization, r#" SELECT id AS "id!: Uuid", name AS "name!", slug AS "slug!", is_personal AS "is_personal!", issue_prefix AS "issue_prefix!", created_at AS "created_at!", updated_at AS "updated_at!" FROM organizations WHERE id = $1 "#, organization_id ) .fetch_optional(self.pool) .await? .ok_or(IdentityError::NotFound) } pub async fn is_personal(&self, organization_id: Uuid) -> Result { is_personal_org(self.pool, organization_id).await } pub async fn ensure_personal_org_and_admin_membership( &self, user_id: Uuid, display_name_hint: Option<&str>, ) -> Result { let name = personal_org_name(display_name_hint, user_id); let slug = personal_org_slug(user_id); // Try to find existing personal org by slug let existing_org = find_organization_by_slug(self.pool, &slug).await?; let org = match existing_org { Some(org) => org, None => { // Create new personal org WITH initial project in a transaction let mut tx = super::begin_tx(self.pool).await?; let org = create_personal_org_tx(&mut *tx, &name, &slug).await?; // Create initial project with default tags and statuses ProjectRepository::create_initial_project_tx(&mut tx, org.id) .await .map_err(|e| { IdentityError::Database(sqlx::Error::Protocol(format!( "Failed to create initial project: {e}" ))) })?; tx.commit().await?; org } }; add_member(self.pool, org.id, user_id, MemberRole::Admin).await?; Ok(org) } pub async fn check_user_role( &self, organization_id: Uuid, user_id: Uuid, ) -> Result, IdentityError> { get_user_role(self.pool, organization_id, user_id).await } pub async fn assert_admin( &self, organization_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { check_admin(self.pool, organization_id, user_id).await } pub async fn create_organization( &self, name: &str, slug: &str, creator_user_id: Uuid, ) -> Result { let mut tx = super::begin_tx(self.pool).await?; let issue_prefix = derive_issue_prefix(name); let org = sqlx::query_as!( Organization, r#" INSERT INTO organizations (name, slug, issue_prefix) VALUES ($1, $2, $3) RETURNING id AS "id!: Uuid", name AS "name!", slug AS "slug!", is_personal AS "is_personal!", issue_prefix AS "issue_prefix!", created_at AS "created_at!", updated_at AS "updated_at!" "#, name, slug, issue_prefix ) .fetch_one(&mut *tx) .await .map_err(|e| { if let Some(db_err) = e.as_database_error() && db_err.is_unique_violation() { return IdentityError::OrganizationConflict( "An organization with this slug already exists".to_string(), ); } IdentityError::from(e) })?; // Create initial project with default tags and statuses ProjectRepository::create_initial_project_tx(&mut tx, org.id) .await .map_err(|e| { IdentityError::Database(sqlx::Error::Protocol(format!( "Failed to create initial project: {e}" ))) })?; add_member(&mut *tx, org.id, creator_user_id, MemberRole::Admin).await?; tx.commit().await?; Ok(OrganizationWithRole { id: org.id, name: org.name, slug: org.slug, is_personal: org.is_personal, issue_prefix: org.issue_prefix, created_at: org.created_at, updated_at: org.updated_at, user_role: MemberRole::Admin, }) } pub async fn list_user_organizations( &self, user_id: Uuid, ) -> Result, IdentityError> { let orgs = sqlx::query_as!( OrganizationWithRole, r#" SELECT o.id AS "id!: Uuid", o.name AS "name!", o.slug AS "slug!", o.is_personal AS "is_personal!", o.issue_prefix AS "issue_prefix!", o.created_at AS "created_at!", o.updated_at AS "updated_at!", m.role AS "user_role!: MemberRole" FROM organizations o JOIN organization_member_metadata m ON m.organization_id = o.id WHERE m.user_id = $1 ORDER BY o.created_at DESC "#, user_id ) .fetch_all(self.pool) .await?; Ok(orgs) } pub async fn update_organization_name( &self, org_id: Uuid, user_id: Uuid, new_name: &str, ) -> Result { self.assert_admin(org_id, user_id).await?; let org = sqlx::query_as!( Organization, r#" UPDATE organizations SET name = $2 WHERE id = $1 RETURNING id AS "id!: Uuid", name AS "name!", slug AS "slug!", is_personal AS "is_personal!", issue_prefix AS "issue_prefix!", created_at AS "created_at!", updated_at AS "updated_at!" "#, org_id, new_name ) .fetch_optional(self.pool) .await? .ok_or(IdentityError::NotFound)?; Ok(org) } pub async fn delete_organization( &self, org_id: Uuid, user_id: Uuid, ) -> Result<(), IdentityError> { // First fetch the org to check if it's a personal org let org = self.fetch_organization(org_id).await?; // Check if this is a personal org by flag if org.is_personal { return Err(IdentityError::CannotDeleteOrganization( "Cannot delete personal organizations".to_string(), )); } let result = sqlx::query!( r#" WITH s AS ( SELECT BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin FROM organization_member_metadata WHERE organization_id = $1 ) DELETE FROM organizations o USING s WHERE o.id = $1 AND s.is_admin = true RETURNING o.id "#, org_id, user_id ) .fetch_optional(self.pool) .await?; if result.is_none() { return Err(IdentityError::PermissionDenied); } Ok(()) } } pub async fn is_personal_org<'e, E>( executor: E, organization_id: Uuid, ) -> Result where E: Executor<'e, Database = Postgres>, { let result: Option = sqlx::query_scalar!( r#"SELECT is_personal FROM organizations WHERE id = $1"#, organization_id ) .fetch_optional(executor) .await?; result.ok_or(IdentityError::NotFound) } async fn find_organization_by_slug( pool: &PgPool, slug: &str, ) -> Result, sqlx::Error> { query_as!( Organization, r#" SELECT id AS "id!: Uuid", name AS "name!", slug AS "slug!", is_personal AS "is_personal!", issue_prefix AS "issue_prefix!", created_at AS "created_at!", updated_at AS "updated_at!" FROM organizations WHERE slug = $1 "#, slug ) .fetch_optional(pool) .await } async fn create_personal_org_tx<'e, E>( executor: E, name: &str, slug: &str, ) -> Result where E: Executor<'e, Database = Postgres>, { let issue_prefix = derive_issue_prefix(name); query_as!( Organization, r#" INSERT INTO organizations (name, slug, is_personal, issue_prefix) VALUES ($1, $2, TRUE, $3) RETURNING id AS "id!: Uuid", name AS "name!", slug AS "slug!", is_personal AS "is_personal!", issue_prefix AS "issue_prefix!", created_at AS "created_at!", updated_at AS "updated_at!" "#, name, slug, issue_prefix ) .fetch_one(executor) .await } fn personal_org_name(hint: Option<&str>, user_id: Uuid) -> String { let user_id_str = user_id.to_string(); let display_name = hint.unwrap_or(&user_id_str); format!("{display_name}'s Org") } fn personal_org_slug(user_id: Uuid) -> String { // Use a deterministic slug pattern so we can find personal orgs format!("personal-{user_id}") } /// Derive an issue prefix from an organization name. /// Takes the first 3 uppercase letters from the name. /// Examples: "Bloop" -> "BLO", "My Project" -> "MYP" fn derive_issue_prefix(name: &str) -> String { let letters: String = name.chars().filter(|c| c.is_ascii_alphabetic()).collect(); let prefix: String = letters.chars().take(3).collect::().to_uppercase(); if prefix.is_empty() { "ISS".to_string() } else { prefix } } ================================================ FILE: crates/remote/src/db/pending_uploads.rs ================================================ use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Clone)] pub struct PendingUpload { pub id: Uuid, pub project_id: Uuid, pub blob_path: String, pub hash: String, pub created_at: DateTime, pub expires_at: DateTime, } #[derive(Debug, Error)] pub enum PendingUploadError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct PendingUploadRepository; impl PendingUploadRepository { pub async fn create( pool: &PgPool, project_id: Uuid, blob_path: String, hash: String, expires_at: DateTime, ) -> Result { let record = sqlx::query_as!( PendingUpload, r#" INSERT INTO pending_uploads (project_id, blob_path, hash, expires_at) VALUES ($1, $2, $3, $4) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", hash AS "hash!", created_at AS "created_at!: DateTime", expires_at AS "expires_at!: DateTime" "#, project_id, blob_path, hash, expires_at, ) .fetch_one(pool) .await?; Ok(record) } pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result, PendingUploadError> { let record = sqlx::query_as!( PendingUpload, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", hash AS "hash!", created_at AS "created_at!: DateTime", expires_at AS "expires_at!: DateTime" FROM pending_uploads WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), PendingUploadError> { sqlx::query!("DELETE FROM pending_uploads WHERE id = $1", id) .execute(pool) .await?; Ok(()) } pub async fn delete_expired(pool: &PgPool) -> Result, PendingUploadError> { let records = sqlx::query_as!( PendingUpload, r#" DELETE FROM pending_uploads WHERE expires_at < NOW() RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", blob_path AS "blob_path!", hash AS "hash!", created_at AS "created_at!: DateTime", expires_at AS "expires_at!: DateTime" "#, ) .fetch_all(pool) .await?; Ok(records) } } ================================================ FILE: crates/remote/src/db/project_notification_preferences.rs ================================================ use serde::{Deserialize, Serialize}; use sqlx::{Executor, Postgres}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectNotificationPreference { pub project_id: Uuid, pub user_id: Uuid, pub notify_on_issue_created: bool, pub notify_on_issue_assigned: bool, } #[derive(Debug, Error)] pub enum ProjectNotificationPreferenceError { #[error(transparent)] Database(#[from] sqlx::Error), } pub struct ProjectNotificationPreferenceRepository; impl ProjectNotificationPreferenceRepository { pub async fn find<'e, E>( executor: E, project_id: Uuid, user_id: Uuid, ) -> Result, ProjectNotificationPreferenceError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( ProjectNotificationPreference, r#" SELECT project_id AS "project_id!: Uuid", user_id AS "user_id!: Uuid", notify_on_issue_created AS "notify_on_issue_created!", notify_on_issue_assigned AS "notify_on_issue_assigned!" FROM project_notification_preferences WHERE project_id = $1 AND user_id = $2 "#, project_id, user_id ) .fetch_optional(executor) .await?; Ok(record) } } ================================================ FILE: crates/remote/src/db/project_statuses.rs ================================================ use api_types::{DeleteResponse, MutationResponse, ProjectStatus}; use chrono::{DateTime, Utc}; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; use super::get_txid; /// Default statuses that are created for each new project (name, color, sort_order, hidden) /// Colors are in HSL format: "H S% L%" pub const DEFAULT_STATUSES: &[(&str, &str, i32, bool)] = &[ ("Backlog", "220 9% 46%", 0, true), ("To do", "217 91% 60%", 1, false), ("In progress", "38 92% 50%", 2, false), ("In review", "258 90% 66%", 3, false), ("Done", "142 71% 45%", 4, false), ("Cancelled", "0 84% 60%", 5, true), ]; #[derive(Debug, Error)] pub enum ProjectStatusError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct ProjectStatusRepository; impl ProjectStatusRepository { pub async fn find_by_id<'e, E>( executor: E, id: Uuid, ) -> Result, ProjectStatusError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( ProjectStatus, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" FROM project_statuses WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } pub async fn find_by_name<'e, E>( executor: E, project_id: Uuid, name: &str, ) -> Result, ProjectStatusError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( ProjectStatus, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" FROM project_statuses WHERE project_id = $1 AND LOWER(name) = LOWER($2) "#, project_id, name ) .fetch_optional(executor) .await?; Ok(record) } pub async fn create( pool: &PgPool, id: Option, project_id: Uuid, name: String, color: String, sort_order: i32, hidden: bool, ) -> Result, ProjectStatusError> { let mut tx = super::begin_tx(pool).await?; let id = id.unwrap_or_else(Uuid::new_v4); let created_at = Utc::now(); let data = sqlx::query_as!( ProjectStatus, r#" INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" "#, id, project_id, name, color, sort_order, hidden, created_at ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Update a project status with partial fields. Uses COALESCE to preserve existing values /// when None is provided. pub async fn update( pool: &PgPool, id: Uuid, name: Option, color: Option, sort_order: Option, hidden: Option, ) -> Result, ProjectStatusError> { let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( ProjectStatus, r#" UPDATE project_statuses SET name = COALESCE($1, name), color = COALESCE($2, color), sort_order = COALESCE($3, sort_order), hidden = COALESCE($4, hidden) WHERE id = $5 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" "#, name, color, sort_order, hidden, id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM project_statuses WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } pub async fn list_by_project<'e, E>( executor: E, project_id: Uuid, ) -> Result, ProjectStatusError> where E: Executor<'e, Database = Postgres>, { let records = sqlx::query_as!( ProjectStatus, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" FROM project_statuses WHERE project_id = $1 "#, project_id ) .fetch_all(executor) .await?; Ok(records) } pub async fn create_default_statuses<'e, E>( executor: E, project_id: Uuid, ) -> Result, ProjectStatusError> where E: Executor<'e, Database = Postgres>, { let names: Vec = DEFAULT_STATUSES .iter() .map(|(n, _, _, _)| (*n).to_string()) .collect(); let colors: Vec = DEFAULT_STATUSES .iter() .map(|(_, c, _, _)| (*c).to_string()) .collect(); let sort_orders: Vec = DEFAULT_STATUSES.iter().map(|(_, _, s, _)| *s).collect(); let hiddens: Vec = DEFAULT_STATUSES.iter().map(|(_, _, _, h)| *h).collect(); let statuses = sqlx::query_as!( ProjectStatus, r#" INSERT INTO project_statuses (id, project_id, name, color, sort_order, hidden, created_at) SELECT gen_random_uuid(), $1, name, color, sort_order, hidden, NOW() FROM UNNEST($2::text[], $3::text[], $4::int[], $5::bool[]) AS t(name, color, sort_order, hidden) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" "#, project_id, &names, &colors, &sort_orders, &hiddens ) .fetch_all(executor) .await?; Ok(statuses) } } ================================================ FILE: crates/remote/src/db/projects.rs ================================================ use api_types::{DeleteResponse, MutationResponse, Project}; use chrono::{DateTime, Utc}; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; use super::{get_txid, project_statuses::ProjectStatusRepository, tags::TagRepository}; /// Default color for the initial project created with personal organizations /// HSL format: "H S% L%" (blue - matches "To do" status) pub const INITIAL_PROJECT_COLOR: &str = "217 91% 60%"; /// Default name for the initial project pub const INITIAL_PROJECT_NAME: &str = "Initial Project"; #[derive(Debug, Error)] pub enum ProjectError { #[error("project conflict: {0}")] Conflict(String), #[error("failed to create default tags: {0}")] DefaultTagsFailed(String), #[error("failed to create default statuses: {0}")] DefaultStatusesFailed(String), #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct ProjectRepository; impl ProjectRepository { pub async fn find_by_id<'e, E>(executor: E, id: Uuid) -> Result, ProjectError> where E: Executor<'e, Database = Postgres>, { let record = sqlx::query_as!( Project, r#" SELECT id AS "id!: Uuid", organization_id AS "organization_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM projects WHERE id = $1 "#, id ) .fetch_optional(executor) .await?; Ok(record) } pub async fn create<'e, E>( executor: E, id: Option, organization_id: Uuid, name: String, color: String, ) -> Result where E: Executor<'e, Database = Postgres>, { let id = id.unwrap_or_else(Uuid::new_v4); let now = Utc::now(); let record = sqlx::query_as!( Project, r#" INSERT INTO projects ( id, organization_id, name, color, sort_order, created_at, updated_at ) VALUES ( $1, $2, $3, $4, COALESCE( (SELECT MAX(sort_order) + 1 FROM projects WHERE organization_id = $2), 0 ), $5, $6 ) RETURNING id AS "id!: Uuid", organization_id AS "organization_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id, organization_id, name, color, now, now ) .fetch_one(executor) .await?; Ok(record) } pub async fn list_by_organization<'e, E>( executor: E, organization_id: Uuid, ) -> Result, ProjectError> where E: Executor<'e, Database = Postgres>, { let records = sqlx::query_as!( Project, r#" SELECT id AS "id!: Uuid", organization_id AS "organization_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM projects WHERE organization_id = $1 ORDER BY sort_order ASC, created_at DESC "#, organization_id ) .fetch_all(executor) .await?; Ok(records) } /// Update a project with partial fields. Uses COALESCE to preserve existing values /// when None is provided. pub async fn update( pool: &PgPool, id: Uuid, name: Option, color: Option, sort_order: Option, ) -> Result, ProjectError> { let mut tx = super::begin_tx(pool).await?; let data = Self::update_partial(&mut *tx, id, name, color, sort_order).await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Updates project fields using a provided executor (used by bulk update transactions). pub async fn update_partial<'e, E>( executor: E, id: Uuid, name: Option, color: Option, sort_order: Option, ) -> Result where E: Executor<'e, Database = Postgres>, { let updated_at = Utc::now(); let record = sqlx::query_as!( Project, r#" UPDATE projects SET name = COALESCE($1, name), color = COALESCE($2, color), sort_order = COALESCE($3, sort_order), updated_at = $4 WHERE id = $5 RETURNING id AS "id!: Uuid", organization_id AS "organization_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, name, color, sort_order, updated_at, id ) .fetch_one(executor) .await?; Ok(record) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM projects WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } pub async fn organization_id<'e, E>( executor: E, project_id: Uuid, ) -> Result, ProjectError> where E: Executor<'e, Database = Postgres>, { sqlx::query_scalar!( r#" SELECT organization_id FROM projects WHERE id = $1 "#, project_id ) .fetch_optional(executor) .await .map_err(ProjectError::from) } /// Creates the initial project for a newly created personal organization. /// Includes default tags and statuses. Designed for use within transactions. pub async fn create_initial_project_tx( tx: &mut sqlx::Transaction<'_, Postgres>, organization_id: Uuid, ) -> Result { let project = Self::create( &mut **tx, None, organization_id, INITIAL_PROJECT_NAME.to_string(), INITIAL_PROJECT_COLOR.to_string(), ) .await?; TagRepository::create_default_tags(&mut **tx, project.id) .await .map_err(|e| ProjectError::DefaultTagsFailed(e.to_string()))?; ProjectStatusRepository::create_default_statuses(&mut **tx, project.id) .await .map_err(|e| ProjectError::DefaultStatusesFailed(e.to_string()))?; Ok(project) } /// Creates a project along with default tags and statuses in a single transaction. pub async fn create_with_defaults( pool: &PgPool, id: Option, organization_id: Uuid, name: String, color: String, ) -> Result, ProjectError> { let mut tx = super::begin_tx(pool).await?; let project = Self::create(&mut *tx, id, organization_id, name, color).await?; TagRepository::create_default_tags(&mut *tx, project.id) .await .map_err(|e| ProjectError::DefaultTagsFailed(e.to_string()))?; ProjectStatusRepository::create_default_statuses(&mut *tx, project.id) .await .map_err(|e| ProjectError::DefaultStatusesFailed(e.to_string()))?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data: project, txid, }) } } ================================================ FILE: crates/remote/src/db/pull_requests.rs ================================================ use api_types::{PullRequest, PullRequestStatus}; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum PullRequestError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct PullRequestRepository; impl PullRequestRepository { pub async fn list_by_issue( pool: &PgPool, issue_id: Uuid, ) -> Result, PullRequestError> { let records = sqlx::query_as!( PullRequest, r#" SELECT id AS "id!: Uuid", url AS "url!: String", number AS "number!: i32", status AS "status!: PullRequestStatus", merged_at AS "merged_at: DateTime", merge_commit_sha AS "merge_commit_sha: String", target_branch_name AS "target_branch_name!: String", issue_id AS "issue_id!: Uuid", workspace_id AS "workspace_id: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM pull_requests WHERE issue_id = $1 "#, issue_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, PullRequestError> { let records = sqlx::query_as!( PullRequest, r#" SELECT id AS "id!: Uuid", url AS "url!: String", number AS "number!: i32", status AS "status!: PullRequestStatus", merged_at AS "merged_at: DateTime", merge_commit_sha AS "merge_commit_sha: String", target_branch_name AS "target_branch_name!: String", issue_id AS "issue_id!: Uuid", workspace_id AS "workspace_id: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM pull_requests WHERE issue_id IN (SELECT id FROM issues WHERE project_id = $1) "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn find_by_url( pool: &PgPool, url: &str, ) -> Result, PullRequestError> { let record = sqlx::query_as!( PullRequest, r#" SELECT id AS "id!: Uuid", url AS "url!: String", number AS "number!: i32", status AS "status!: PullRequestStatus", merged_at AS "merged_at: DateTime", merge_commit_sha AS "merge_commit_sha: String", target_branch_name AS "target_branch_name!: String", issue_id AS "issue_id!: Uuid", workspace_id AS "workspace_id: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM pull_requests WHERE url = $1 "#, url ) .fetch_optional(pool) .await?; Ok(record) } #[allow(clippy::too_many_arguments)] pub async fn create( pool: &PgPool, url: String, number: i32, status: PullRequestStatus, merged_at: Option>, merge_commit_sha: Option, target_branch_name: String, issue_id: Uuid, workspace_id: Option, ) -> Result { let id = Uuid::new_v4(); let record = sqlx::query_as!( PullRequest, r#" INSERT INTO pull_requests ( id, url, number, status, merged_at, merge_commit_sha, target_branch_name, issue_id, workspace_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id AS "id!: Uuid", url AS "url!: String", number AS "number!: i32", status AS "status!: PullRequestStatus", merged_at AS "merged_at: DateTime", merge_commit_sha AS "merge_commit_sha: String", target_branch_name AS "target_branch_name!: String", issue_id AS "issue_id!: Uuid", workspace_id AS "workspace_id: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, id, url, number, status as PullRequestStatus, merged_at, merge_commit_sha, target_branch_name, issue_id, workspace_id ) .fetch_one(pool) .await?; Ok(record) } pub async fn update( pool: &PgPool, id: Uuid, status: Option, merged_at: Option>>, merge_commit_sha: Option>, ) -> Result { let update_status = status.is_some(); let status_value = status.unwrap_or(PullRequestStatus::Open); let update_merged_at = merged_at.is_some(); let merged_at_value = merged_at.flatten(); let update_merge_commit_sha = merge_commit_sha.is_some(); let merge_commit_sha_value = merge_commit_sha.flatten(); let record = sqlx::query_as!( PullRequest, r#" UPDATE pull_requests SET status = CASE WHEN $1 THEN $2 ELSE status END, merged_at = CASE WHEN $3 THEN $4 ELSE merged_at END, merge_commit_sha = CASE WHEN $5 THEN $6 ELSE merge_commit_sha END, updated_at = NOW() WHERE id = $7 RETURNING id AS "id!: Uuid", url AS "url!: String", number AS "number!: i32", status AS "status!: PullRequestStatus", merged_at AS "merged_at: DateTime", merge_commit_sha AS "merge_commit_sha: String", target_branch_name AS "target_branch_name!: String", issue_id AS "issue_id!: Uuid", workspace_id AS "workspace_id: Uuid", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, update_status, status_value as PullRequestStatus, update_merged_at, merged_at_value, update_merge_commit_sha, merge_commit_sha_value, id ) .fetch_one(pool) .await?; Ok(record) } } ================================================ FILE: crates/remote/src/db/reviews.rs ================================================ use std::net::IpAddr; use chrono::{DateTime, Utc}; use ipnetwork::IpNetwork; use serde::Serialize; use sqlx::{PgPool, query_as}; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum ReviewError { #[error("review not found")] NotFound, #[error(transparent)] Database(#[from] sqlx::Error), } #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct Review { pub id: Uuid, pub gh_pr_url: String, pub claude_code_session_id: Option, pub ip_address: Option, pub review_cache: Option, pub last_viewed_at: Option>, pub r2_path: String, pub deleted_at: Option>, pub created_at: DateTime, pub email: Option, pub pr_title: String, pub status: String, // Webhook-specific fields pub github_installation_id: Option, pub pr_owner: Option, pub pr_repo: Option, pub pr_number: Option, } impl Review { /// Returns true if this review was triggered by a GitHub webhook pub fn is_webhook_review(&self) -> bool { self.github_installation_id.is_some() } } /// Parameters for creating a new review (CLI-triggered) pub struct CreateReviewParams<'a> { pub id: Uuid, pub gh_pr_url: &'a str, pub claude_code_session_id: Option<&'a str>, pub ip_address: IpAddr, pub r2_path: &'a str, pub email: &'a str, pub pr_title: &'a str, } /// Parameters for creating a webhook-triggered review pub struct CreateWebhookReviewParams<'a> { pub id: Uuid, pub gh_pr_url: &'a str, pub r2_path: &'a str, pub pr_title: &'a str, pub github_installation_id: i64, pub pr_owner: &'a str, pub pr_repo: &'a str, pub pr_number: i32, } pub struct ReviewRepository<'a> { pool: &'a PgPool, } impl<'a> ReviewRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn create(&self, params: CreateReviewParams<'_>) -> Result { let ip_network = IpNetwork::from(params.ip_address); query_as!( Review, r#" INSERT INTO reviews (id, gh_pr_url, claude_code_session_id, ip_address, r2_path, email, pr_title) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, gh_pr_url, claude_code_session_id, ip_address AS "ip_address: IpNetwork", review_cache, last_viewed_at, r2_path, deleted_at, created_at, email, pr_title, status, github_installation_id, pr_owner, pr_repo, pr_number "#, params.id, params.gh_pr_url, params.claude_code_session_id, ip_network, params.r2_path, params.email, params.pr_title ) .fetch_one(self.pool) .await .map_err(ReviewError::from) } /// Create a webhook-triggered review (no email/IP) pub async fn create_webhook_review( &self, params: CreateWebhookReviewParams<'_>, ) -> Result { query_as!( Review, r#" INSERT INTO reviews (id, gh_pr_url, r2_path, pr_title, github_installation_id, pr_owner, pr_repo, pr_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, gh_pr_url, claude_code_session_id, ip_address AS "ip_address: IpNetwork", review_cache, last_viewed_at, r2_path, deleted_at, created_at, email, pr_title, status, github_installation_id, pr_owner, pr_repo, pr_number "#, params.id, params.gh_pr_url, params.r2_path, params.pr_title, params.github_installation_id, params.pr_owner, params.pr_repo, params.pr_number ) .fetch_one(self.pool) .await .map_err(ReviewError::from) } /// Get a review by its ID. /// Returns NotFound if the review doesn't exist or has been deleted. pub async fn get_by_id(&self, id: Uuid) -> Result { query_as!( Review, r#" SELECT id, gh_pr_url, claude_code_session_id, ip_address AS "ip_address: IpNetwork", review_cache, last_viewed_at, r2_path, deleted_at, created_at, email, pr_title, status, github_installation_id, pr_owner, pr_repo, pr_number FROM reviews WHERE id = $1 AND deleted_at IS NULL "#, id ) .fetch_optional(self.pool) .await? .ok_or(ReviewError::NotFound) } /// Count reviews from an IP address since a given timestamp. /// Used for rate limiting. pub async fn count_since( &self, ip_address: IpAddr, since: DateTime, ) -> Result { let ip_network = IpNetwork::from(ip_address); let result = sqlx::query!( r#" SELECT COUNT(*) as "count!" FROM reviews WHERE ip_address = $1 AND created_at > $2 AND deleted_at IS NULL "#, ip_network, since ) .fetch_one(self.pool) .await .map_err(ReviewError::from)?; Ok(result.count) } /// Mark a review as completed pub async fn mark_completed(&self, id: Uuid) -> Result<(), ReviewError> { sqlx::query!( r#" UPDATE reviews SET status = 'completed' WHERE id = $1 AND deleted_at IS NULL "#, id ) .execute(self.pool) .await .map_err(ReviewError::from)?; Ok(()) } /// Mark a review as failed pub async fn mark_failed(&self, id: Uuid) -> Result<(), ReviewError> { sqlx::query!( r#" UPDATE reviews SET status = 'failed' WHERE id = $1 AND deleted_at IS NULL "#, id ) .execute(self.pool) .await .map_err(ReviewError::from)?; Ok(()) } /// Check if there's a pending review for a specific PR pub async fn has_pending_review_for_pr( &self, pr_owner: &str, pr_repo: &str, pr_number: i32, ) -> Result { let result = sqlx::query!( r#" SELECT EXISTS( SELECT 1 FROM reviews WHERE pr_owner = $1 AND pr_repo = $2 AND pr_number = $3 AND status = 'pending' AND deleted_at IS NULL ) as "exists!" "#, pr_owner, pr_repo, pr_number ) .fetch_one(self.pool) .await?; Ok(result.exists) } } ================================================ FILE: crates/remote/src/db/tags.rs ================================================ use api_types::{DeleteResponse, MutationResponse, Tag}; use sqlx::{Executor, PgPool, Postgres}; use thiserror::Error; use uuid::Uuid; use super::get_txid; #[derive(Debug, Error)] pub enum TagError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } /// Default tags that are created for each new project /// Colors are in HSL format: "H S% L%" pub const DEFAULT_TAGS: &[(&str, &str)] = &[ ("bug", "355 65% 53%"), ("feature", "124 82% 30%"), ("documentation", "205 100% 40%"), ("enhancement", "181 72% 78%"), ]; pub struct TagRepository; impl TagRepository { pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, TagError> { let record = sqlx::query_as!( Tag, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!" FROM tags WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn create( pool: &PgPool, id: Option, project_id: Uuid, name: String, color: String, ) -> Result, TagError> { let mut tx = super::begin_tx(pool).await?; let id = id.unwrap_or_else(Uuid::new_v4); let data = sqlx::query_as!( Tag, r#" INSERT INTO tags (id, project_id, name, color) VALUES ($1, $2, $3, $4) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!" "#, id, project_id, name, color ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } /// Update a tag with partial fields. Uses COALESCE to preserve existing values /// when None is provided. pub async fn update( pool: &PgPool, id: Uuid, name: Option, color: Option, ) -> Result, TagError> { let mut tx = super::begin_tx(pool).await?; let data = sqlx::query_as!( Tag, r#" UPDATE tags SET name = COALESCE($1, name), color = COALESCE($2, color) WHERE id = $3 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!" "#, name, color, id ) .fetch_one(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(MutationResponse { data, txid }) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result { let mut tx = super::begin_tx(pool).await?; sqlx::query!("DELETE FROM tags WHERE id = $1", id) .execute(&mut *tx) .await?; let txid = get_txid(&mut *tx).await?; tx.commit().await?; Ok(DeleteResponse { txid }) } pub async fn list_by_project(pool: &PgPool, project_id: Uuid) -> Result, TagError> { let records = sqlx::query_as!( Tag, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!" FROM tags WHERE project_id = $1 "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create_default_tags<'e, E>( executor: E, project_id: Uuid, ) -> Result, TagError> where E: Executor<'e, Database = Postgres>, { let names: Vec = DEFAULT_TAGS.iter().map(|(n, _)| (*n).to_string()).collect(); let colors: Vec = DEFAULT_TAGS.iter().map(|(_, c)| (*c).to_string()).collect(); let tags = sqlx::query_as!( Tag, r#" INSERT INTO tags (id, project_id, name, color) SELECT gen_random_uuid(), $1, name, color FROM UNNEST($2::text[], $3::text[]) AS t(name, color) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!" "#, project_id, &names, &colors ) .fetch_all(executor) .await?; Ok(tags) } } ================================================ FILE: crates/remote/src/db/types.rs ================================================ /// Validates that a string is in HSL format: "H S% L%" /// where H is 0-360, S is 0-100%, L is 0-100% pub fn is_valid_hsl_color(color: &str) -> bool { let parts: Vec<&str> = color.split(' ').collect(); if parts.len() != 3 { return false; } // Parse hue (0-360) let Some(h) = parts[0].parse::().ok() else { return false; }; if h > 360 { return false; } // Parse saturation (0-100%) let Some(s_str) = parts[1].strip_suffix('%') else { return false; }; let Some(s) = s_str.parse::().ok() else { return false; }; if s > 100 { return false; } // Parse lightness (0-100%) let Some(l_str) = parts[2].strip_suffix('%') else { return false; }; let Some(l) = l_str.parse::().ok() else { return false; }; if l > 100 { return false; } true } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_hsl_colors() { assert!(is_valid_hsl_color("0 0% 0%")); assert!(is_valid_hsl_color("360 100% 100%")); assert!(is_valid_hsl_color("217 91% 60%")); assert!(is_valid_hsl_color("355 65% 53%")); assert!(is_valid_hsl_color("220 9% 46%")); } #[test] fn test_invalid_hsl_colors() { assert!(!is_valid_hsl_color("#ff0000")); // HEX format assert!(!is_valid_hsl_color("361 50% 50%")); // Hue out of range assert!(!is_valid_hsl_color("180 101% 50%")); // Saturation out of range assert!(!is_valid_hsl_color("180 50% 101%")); // Lightness out of range assert!(!is_valid_hsl_color("180 50 50%")); // Missing % on saturation assert!(!is_valid_hsl_color("180 50% 50")); // Missing % on lightness assert!(!is_valid_hsl_color("hsl(180, 50%, 50%)")); // Wrong format assert!(!is_valid_hsl_color("180, 50%, 50%")); // Wrong separator assert!(!is_valid_hsl_color("")); // Empty } } ================================================ FILE: crates/remote/src/db/users.rs ================================================ use api_types::{User, UserData}; use sqlx::{PgPool, query_as}; use uuid::Uuid; use super::{Tx, identity_errors::IdentityError}; #[derive(Debug, Clone)] pub struct UpsertUser<'a> { pub id: Uuid, pub email: &'a str, pub first_name: Option<&'a str>, pub last_name: Option<&'a str>, pub username: Option<&'a str>, } pub struct UserRepository<'a> { pool: &'a PgPool, } impl<'a> UserRepository<'a> { pub fn new(pool: &'a PgPool) -> Self { Self { pool } } pub async fn upsert_user(&self, user: UpsertUser<'_>) -> Result { upsert_user(self.pool, &user) .await .map_err(IdentityError::from) } pub async fn fetch_user(&self, user_id: Uuid) -> Result { query_as!( User, r#" SELECT id AS "id!: Uuid", email AS "email!", first_name AS "first_name?", last_name AS "last_name?", username AS "username?", created_at AS "created_at!", updated_at AS "updated_at!" FROM users WHERE id = $1 "#, user_id ) .fetch_optional(self.pool) .await? .ok_or(IdentityError::NotFound) } } async fn upsert_user(pool: &PgPool, user: &UpsertUser<'_>) -> Result { query_as!( User, r#" INSERT INTO users (id, email, first_name, last_name, username) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, username = EXCLUDED.username RETURNING id AS "id!: Uuid", email AS "email!", first_name AS "first_name?", last_name AS "last_name?", username AS "username?", created_at AS "created_at!", updated_at AS "updated_at!" "#, user.id, user.email, user.first_name, user.last_name, user.username ) .fetch_one(pool) .await } pub async fn fetch_user(tx: &mut Tx<'_>, user_id: Uuid) -> Result, IdentityError> { sqlx::query!( r#" SELECT id AS "id!: Uuid", first_name AS "first_name?", last_name AS "last_name?", username AS "username?" FROM users WHERE id = $1 "#, user_id ) .fetch_optional(&mut **tx) .await .map_err(IdentityError::from) .map(|row_opt| { row_opt.map(|row| UserData { user_id: row.id, first_name: row.first_name, last_name: row.last_name, username: row.username, }) }) } ================================================ FILE: crates/remote/src/db/workspaces.rs ================================================ use api_types::Workspace; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum WorkspaceError { #[error("database error: {0}")] Database(#[from] sqlx::Error), } pub struct CreateWorkspaceParams { pub project_id: Uuid, pub owner_user_id: Uuid, pub local_workspace_id: Option, pub issue_id: Option, pub name: Option, pub archived: Option, pub files_changed: Option, pub lines_added: Option, pub lines_removed: Option, } pub struct WorkspaceRepository; impl WorkspaceRepository { pub async fn list_by_owner( pool: &PgPool, owner_user_id: Uuid, ) -> Result, WorkspaceError> { let records = sqlx::query_as!( Workspace, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM workspaces WHERE owner_user_id = $1 "#, owner_user_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn list_by_project( pool: &PgPool, project_id: Uuid, ) -> Result, WorkspaceError> { let records = sqlx::query_as!( Workspace, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM workspaces WHERE project_id = $1 "#, project_id ) .fetch_all(pool) .await?; Ok(records) } pub async fn create( pool: &PgPool, params: CreateWorkspaceParams, ) -> Result { let CreateWorkspaceParams { project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed, } = params; let archived = archived.unwrap_or(false); let record = sqlx::query_as!( Workspace, r#" INSERT INTO workspaces (project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, project_id, owner_user_id, local_workspace_id, issue_id, name, archived, files_changed, lines_added, lines_removed ) .fetch_one(pool) .await?; Ok(record) } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, WorkspaceError> { let record = sqlx::query_as!( Workspace, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM workspaces WHERE id = $1 "#, id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn find_by_local_id( pool: &PgPool, local_workspace_id: Uuid, ) -> Result, WorkspaceError> { let record = sqlx::query_as!( Workspace, r#" SELECT id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" FROM workspaces WHERE local_workspace_id = $1 "#, local_workspace_id ) .fetch_optional(pool) .await?; Ok(record) } pub async fn exists_by_local_id( pool: &PgPool, local_workspace_id: Uuid, ) -> Result { let exists = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM workspaces WHERE local_workspace_id = $1) AS "exists!""#, local_workspace_id ) .fetch_one(pool) .await?; Ok(exists) } pub async fn delete_by_local_id( pool: &PgPool, local_workspace_id: Uuid, ) -> Result<(), WorkspaceError> { sqlx::query!( "DELETE FROM workspaces WHERE local_workspace_id = $1", local_workspace_id ) .execute(pool) .await?; Ok(()) } pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), WorkspaceError> { sqlx::query!("DELETE FROM workspaces WHERE id = $1", id) .execute(pool) .await?; Ok(()) } pub async fn count_by_issue_id(pool: &PgPool, issue_id: Uuid) -> Result { let count = sqlx::query_scalar!( r#"SELECT COUNT(*) AS "count!" FROM workspaces WHERE issue_id = $1"#, issue_id ) .fetch_one(pool) .await?; Ok(count) } pub async fn update( pool: &PgPool, id: Uuid, name: Option>, archived: Option, files_changed: Option>, lines_added: Option>, lines_removed: Option>, ) -> Result { let update_name = name.is_some(); let name_value = name.flatten(); let update_archived = archived.is_some(); let archived_value = archived.unwrap_or(false); let update_files_changed = files_changed.is_some(); let files_changed_value = files_changed.flatten(); let update_lines_added = lines_added.is_some(); let lines_added_value = lines_added.flatten(); let update_lines_removed = lines_removed.is_some(); let lines_removed_value = lines_removed.flatten(); let record = sqlx::query_as!( Workspace, r#" UPDATE workspaces SET name = CASE WHEN $1 THEN $2 ELSE name END, archived = CASE WHEN $3 THEN $4 ELSE archived END, files_changed = CASE WHEN $5 THEN $6 ELSE files_changed END, lines_added = CASE WHEN $7 THEN $8 ELSE lines_added END, lines_removed = CASE WHEN $9 THEN $10 ELSE lines_removed END, updated_at = NOW() WHERE id = $11 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", owner_user_id AS "owner_user_id!: Uuid", issue_id AS "issue_id: Uuid", local_workspace_id AS "local_workspace_id: Uuid", name AS "name: String", archived AS "archived!: bool", files_changed AS "files_changed: i32", lines_added AS "lines_added: i32", lines_removed AS "lines_removed: i32", created_at AS "created_at!: DateTime", updated_at AS "updated_at!: DateTime" "#, update_name, name_value, update_archived, archived_value, update_files_changed, files_changed_value, update_lines_added, lines_added_value, update_lines_removed, lines_removed_value, id ) .fetch_one(pool) .await?; Ok(record) } } ================================================ FILE: crates/remote/src/digest/email.rs ================================================ use std::collections::{HashMap, VecDeque}; use api_types::{NotificationPayload, NotificationType}; use uuid::Uuid; use crate::{ db::digest::NotificationDigestRow, mail::{DIGEST_PREVIEW_COUNT, DigestNotificationItem}, }; pub fn build_digest_items( rows: &[NotificationDigestRow], base_url: &str, ) -> Vec { let preview_rows = select_preview_rows(rows); preview_rows .iter() .map(|row| { let payload = &row.payload.0; let deeplink = absolute_url(base_url, payload.deeplink_path.as_deref().unwrap_or("")); let copy = build_digest_copy(row); DigestNotificationItem { title: copy.title, body: copy.body.unwrap_or_default(), url: deeplink, } }) .collect() } pub fn notifications_url(base_url: &str) -> String { absolute_url(base_url, "/notifications") } fn select_preview_rows(rows: &[NotificationDigestRow]) -> Vec<&NotificationDigestRow> { let mut groups = build_preview_groups(rows); let mut selected = Vec::with_capacity(DIGEST_PREVIEW_COUNT.min(rows.len())); while selected.len() < DIGEST_PREVIEW_COUNT { let mut added_in_pass = false; for group in &mut groups { if let Some(row) = group.rows.pop_front() { selected.push(row); added_in_pass = true; if selected.len() == DIGEST_PREVIEW_COUNT { break; } } } if !added_in_pass { break; } } selected } struct PreviewGroup<'a> { rows: VecDeque<&'a NotificationDigestRow>, } fn build_preview_groups(rows: &[NotificationDigestRow]) -> Vec> { let mut groups: Vec> = Vec::new(); let mut issue_group_indexes: HashMap = HashMap::new(); for row in rows { if let Some(issue_id) = preview_issue_id(row) { if let Some(index) = issue_group_indexes.get(&issue_id).copied() { groups[index].rows.push_back(row); } else { let index = groups.len(); groups.push(PreviewGroup { rows: VecDeque::from([row]), }); issue_group_indexes.insert(issue_id, index); } } else { groups.push(PreviewGroup { rows: VecDeque::from([row]), }); } } groups } fn preview_issue_id(row: &NotificationDigestRow) -> Option { row.payload.0.issue_id.or(row.issue_id) } struct DigestCopy { title: String, body: Option, } fn build_digest_copy(row: &NotificationDigestRow) -> DigestCopy { let payload = &row.payload.0; let actor_name = &row.actor_name; let issue_label = issue_label(payload); let (title, body) = match row.notification_type { NotificationType::IssueCommentAdded => ( format!("{actor_name} commented on {issue_label}"), payload .comment_preview .as_deref() .map(clean_preview_text) .filter(|value| !value.is_empty()) .map(|value| format!("\"{}\"", truncate_text(&value, 177))) .or_else(|| issue_context(payload)), ), NotificationType::IssueStatusChanged => { let old_status = clean_optional_text(payload.old_status_name.as_deref()); let new_status = clean_optional_text(payload.new_status_name.as_deref()); let title = match (&old_status, &new_status) { (Some(old_status), Some(new_status)) => format!( "{actor_name} changed status of {issue_label} from {old_status} to {new_status}" ), (_, Some(new_status)) => { format!("{actor_name} changed status of {issue_label} to {new_status}") } _ => format!("{actor_name} changed status of {issue_label}"), }; (title, issue_context(payload)) } NotificationType::IssueAssigneeChanged => ( format!("You were assigned to {issue_label} by {actor_name}"), issue_context(payload), ), NotificationType::IssuePriorityChanged => { let old_priority = payload.old_priority.map(priority_label); let new_priority = payload.new_priority.map(priority_label); let title = match (&old_priority, &new_priority) { (Some(old_priority), Some(new_priority)) => format!( "{actor_name} changed the priority of {issue_label} from {old_priority} to {new_priority}" ), (None, Some(new_priority)) => { format!("{actor_name} changed the priority of {issue_label} to {new_priority}") } _ => format!("{actor_name} changed the priority of {issue_label}"), }; let body = match (old_priority, new_priority) { (Some(old_priority), Some(new_priority)) => Some(format!( "Priority changed from {old_priority} to {new_priority}." )), (_, Some(new_priority)) => Some(format!("Priority changed to {new_priority}.")), _ => None, }; (title, body) } NotificationType::IssueUnassigned => ( format!("{actor_name} unassigned you from {issue_label}"), issue_context(payload), ), NotificationType::IssueCommentReaction => { let emoji = clean_optional_text(payload.emoji.as_deref()); let title = match &emoji { Some(emoji) => { format!("{actor_name} reacted {emoji} to your comment on {issue_label}") } None => format!("{actor_name} reacted to your comment on {issue_label}"), }; let body = emoji.map(|emoji| format!("Reacted with {emoji} to your comment.")); (title, body) } NotificationType::IssueDeleted => ( format!("{actor_name} deleted {issue_label}"), issue_context(payload), ), NotificationType::IssueTitleChanged => { let new_title = clean_optional_text(payload.new_title.as_deref()); let title = new_title .as_ref() .map(|value| format!("{actor_name} changed the title of {issue_label} to {value}")) .unwrap_or_else(|| format!("{actor_name} changed the title of {issue_label}")); let body = new_title .map(|new_title| format!("New title: {new_title}")) .or_else(|| issue_context(payload)); (title, body) } NotificationType::IssueDescriptionChanged => ( format!("{actor_name} changed the description on {issue_label}"), issue_context(payload).map(|issue| format!("Updated the description on {issue}.")), ), }; DigestCopy { title, body: body.map(|value| truncate_text(&value, 180)), } } fn issue_context(payload: &NotificationPayload) -> Option { clean_optional_text(payload.issue_title.as_deref()) .or_else(|| clean_optional_text(payload.issue_simple_id.as_deref())) } fn issue_label(payload: &NotificationPayload) -> String { clean_optional_text(payload.issue_simple_id.as_deref()).unwrap_or_else(|| "issue".to_string()) } fn priority_label(priority: api_types::IssuePriority) -> &'static str { match priority { api_types::IssuePriority::Urgent => "Urgent", api_types::IssuePriority::High => "High", api_types::IssuePriority::Medium => "Medium", api_types::IssuePriority::Low => "Low", } } fn clean_optional_text(value: Option<&str>) -> Option { value .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn clean_preview_text(value: &str) -> String { value.split_whitespace().collect::>().join(" ") } fn truncate_text(value: &str, max_chars: usize) -> String { let trimmed = value.trim(); let char_count = trimmed.chars().count(); if char_count <= max_chars { return trimmed.to_string(); } let truncated = trimmed.chars().take(max_chars).collect::(); format!("{}...", truncated.trim_end()) } fn absolute_url(base_url: &str, deeplink_path: &str) -> String { let base_url = base_url.trim_end_matches('/'); let deeplink_path = deeplink_path.trim_start_matches('/'); format!("{base_url}/{deeplink_path}") } ================================================ FILE: crates/remote/src/digest/index.mjml ================================================ a { color: #D07A2F; text-decoration: none; } .notification-title a { font-weight: 700; font-size: 14px; line-height: 1.4; color: #D07A2F; text-decoration: none; } .notification-body { margin-top: 8px; color: #262626; font-size: 14px; line-height: 1.5; } Hey {firstName}, you have {EVENT_PROPERTY:notificationCount} new notifications {EVENT_PROPERTY:notification0Title} {EVENT_PROPERTY:notification0Body} {EVENT_PROPERTY:notification1Title} {EVENT_PROPERTY:notification1Body} {EVENT_PROPERTY:notification2Title} {EVENT_PROPERTY:notification2Body} {EVENT_PROPERTY:notification3Title} {EVENT_PROPERTY:notification3Body} {EVENT_PROPERTY:notification4Title} {EVENT_PROPERTY:notification4Body} View all notifications You're receiving this because you have unread notifications on Vibe Kanban. Unsubscribe ================================================ FILE: crates/remote/src/digest/mod.rs ================================================ pub mod email; pub mod task; use std::time::Duration; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use tracing::{info, warn}; use crate::{ db::digest::DigestRepository, mail::{DIGEST_PREVIEW_COUNT, DigestContact, Mailer}, }; #[derive(Debug, Clone, sqlx::FromRow)] pub struct DigestUser { pub id: uuid::Uuid, pub email: String, pub first_name: Option, pub last_name: Option, pub username: Option, } #[derive(Debug, Default)] pub struct DigestStats { pub users_processed: u32, pub emails_sent: u32, pub errors: u32, } #[derive(Debug, Error)] pub enum DigestError { #[error("digest database error: {0}")] Database(#[from] sqlx::Error), #[error("loops event failed for digest: status={status}, body={body}")] LoopsSendFailed { status: reqwest::StatusCode, body: String, }, #[error("loops request error for digest: {0}")] LoopsRequest(#[from] reqwest::Error), #[error("invalid digest window duration")] InvalidWindowDuration, } pub async fn run_email_digest( pool: &PgPool, mailer: &dyn Mailer, base_url: &str, now: DateTime, window: Duration, send_delay: Duration, ) -> Result { let (window_start, window_end) = digest_window(now, window)?; let mut stats = DigestStats::default(); let users = DigestRepository::fetch_users_with_pending_notifications(pool, window_start, window_end) .await?; info!( window_start = %window_start, window_end = %window_end, user_count = users.len(), "Digest: found users with pending notifications" ); for user in &users { stats.users_processed += 1; match process_user_digest(pool, mailer, base_url, user, window_start, window_end).await { Ok(sent) => stats.emails_sent += sent, Err(e) => { warn!(user_id = %user.id, error = %e, "Digest: failed to process user"); stats.errors += 1; } } if !send_delay.is_zero() { tokio::time::sleep(send_delay).await; } } Ok(stats) } async fn process_user_digest( pool: &PgPool, mailer: &dyn Mailer, base_url: &str, user: &DigestUser, window_start: DateTime, window_end: DateTime, ) -> Result { let notification_rows = DigestRepository::fetch_notifications_for_user(pool, user.id, window_start, window_end) .await?; if notification_rows.len() < DIGEST_PREVIEW_COUNT { return Ok(0); } let total_count = notification_rows.len() as i32; let notification_ids = notification_rows .iter() .map(|row| row.id) .collect::>(); let items = email::build_digest_items(¬ification_rows, base_url); let notifications_url = email::notifications_url(base_url); let contact = DigestContact { email: &user.email, user_id: &user.id.to_string(), first_name: user.first_name.as_deref(), last_name: user.last_name.as_deref(), }; mailer .send_digest_event(&contact, total_count, &items, ¬ifications_url) .await?; DigestRepository::record_notifications_delivered(pool, ¬ification_ids).await?; Ok(1) } fn digest_window( now: DateTime, window: Duration, ) -> Result<(DateTime, DateTime), DigestError> { let lookback = chrono::Duration::from_std(window).map_err(|_| DigestError::InvalidWindowDuration)?; let window_end = now; let window_start = window_end - lookback; Ok((window_start, window_end)) } ================================================ FILE: crates/remote/src/digest/task.rs ================================================ use std::{panic::AssertUnwindSafe, sync::Arc, time::Duration}; use chrono::{DateTime, Days, Timelike, Utc}; use futures::FutureExt; use sqlx::PgPool; use tokio::task::JoinHandle; use tracing::{error, info, warn}; use crate::{ db::digest::{DigestRepository, DigestRunLock}, digest::run_email_digest, mail::Mailer, }; const DEFAULT_WINDOW: Duration = Duration::from_secs(86400); const DEFAULT_RUN_HOUR_UTC: u32 = 8; const DEFAULT_SEND_DELAY: Duration = Duration::from_millis(100); pub fn spawn_digest_task( pool: PgPool, mailer: Arc, base_url: String, ) -> JoinHandle<()> { let interval_override = std::env::var("DIGEST_INTERVAL_SECS_OVERRIDE") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_secs); let run_hour_utc = std::env::var("DIGEST_RUN_HOUR_UTC") .ok() .and_then(|v| v.parse::().ok()) .filter(|hour| *hour < 24) .unwrap_or(DEFAULT_RUN_HOUR_UTC); let window = std::env::var("DIGEST_WINDOW_SECS_OVERRIDE") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_secs) .unwrap_or(DEFAULT_WINDOW); let send_delay = std::env::var("DIGEST_SEND_DELAY_MS") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_millis) .unwrap_or(DEFAULT_SEND_DELAY); match interval_override { Some(interval) => info!( interval_secs = interval.as_secs(), window_secs = window.as_secs(), "Starting notification digest background task with interval override" ), None => info!( run_hour_utc, window_secs = window.as_secs(), "Starting notification digest background task" ), } tokio::spawn(async move { let result = AssertUnwindSafe(digest_loop( &pool, mailer.as_ref(), &base_url, interval_override, run_hour_utc, window, send_delay, )); if let Err(panic) = result.catch_unwind().await { let msg = panic .downcast_ref::<&str>() .map(|s| s.to_string()) .or_else(|| panic.downcast_ref::().cloned()) .unwrap_or_else(|| "unknown panic".to_string()); error!(panic = %msg, "Notification digest task died — digests will not be sent until next deploy"); } }) } async fn digest_loop( pool: &PgPool, mailer: &dyn Mailer, base_url: &str, interval_override: Option, run_hour_utc: u32, window: Duration, send_delay: Duration, ) { loop { if let Some(interval) = interval_override { tokio::time::sleep(interval).await; } else { let now = Utc::now(); let next_run = next_run_at(now, run_hour_utc); let sleep_duration = (next_run - now) .to_std() .unwrap_or_else(|_| Duration::from_secs(0)); info!(next_run = %next_run, sleep_secs = sleep_duration.as_secs(), "Next notification digest scheduled"); tokio::time::sleep(sleep_duration).await; } let Some(lock) = acquire_run_lock(pool).await else { continue; }; match run_email_digest(pool, mailer, base_url, Utc::now(), window, send_delay).await { Ok(stats) => { info!( users_processed = stats.users_processed, emails_sent = stats.emails_sent, errors = stats.errors, "Notification digest cycle complete" ); } Err(e) => { error!(error = %e, "Notification digest cycle failed"); } } if let Err(error) = lock.release().await { warn!(error = %error, "Failed to release notification digest lock"); } } } async fn acquire_run_lock(pool: &PgPool) -> Option { match DigestRepository::try_acquire_run_lock(pool).await { Ok(Some(lock)) => Some(lock), Ok(None) => { info!("Skipping notification digest cycle because another instance is running it"); None } Err(error) => { error!(error = %error, "Failed to acquire notification digest lock"); None } } } fn next_run_at(now: DateTime, run_hour_utc: u32) -> DateTime { let today = now.date_naive(); let today_run = today .and_hms_opt(run_hour_utc, 0, 0) .expect("validated digest hour"); let next_naive = if now.hour() < run_hour_utc { today_run } else { today .checked_add_days(Days::new(1)) .expect("date overflow for digest schedule") .and_hms_opt(run_hour_utc, 0, 0) .expect("validated digest hour") }; DateTime::from_naive_utc_and_offset(next_naive, Utc) } ================================================ FILE: crates/remote/src/github_app/jwt.rs ================================================ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; use thiserror::Error; /// JWT generator for GitHub App authentication. /// GitHub Apps authenticate using RS256-signed JWTs with a 10-minute max TTL. #[derive(Clone)] pub struct GitHubAppJwt { app_id: u64, private_key_pem: SecretString, } #[derive(Debug, Error)] pub enum JwtError { #[error("invalid private key: {0}")] InvalidPrivateKey(String), #[error("failed to encode JWT: {0}")] EncodingError(#[from] jsonwebtoken::errors::Error), #[error("invalid base64 encoding")] Base64Error, } #[derive(Debug, Serialize)] struct GitHubAppClaims { /// Issuer - the GitHub App ID iss: String, /// Issued at (Unix timestamp) iat: i64, /// Expiration (Unix timestamp) - max 10 minutes from iat exp: i64, } impl GitHubAppJwt { /// Create a new JWT generator from base64-encoded PEM private key pub fn new(app_id: u64, private_key_base64: SecretString) -> Result { // Decode base64 to get raw PEM let pem_bytes = BASE64_STANDARD .decode(private_key_base64.expose_secret().as_bytes()) .map_err(|_| JwtError::Base64Error)?; let pem_string = String::from_utf8(pem_bytes) .map_err(|_| JwtError::InvalidPrivateKey("PEM is not valid UTF-8".to_string()))?; // Validate we can parse this as an RSA key EncodingKey::from_rsa_pem(pem_string.as_bytes()) .map_err(|e| JwtError::InvalidPrivateKey(e.to_string()))?; Ok(Self { app_id, private_key_pem: SecretString::new(pem_string.into()), }) } /// Generate a JWT for authenticating as the GitHub App. /// This JWT is used to get installation access tokens. /// Max TTL is 10 minutes as per GitHub's requirements. pub fn generate(&self) -> Result { let now = chrono::Utc::now().timestamp(); // Subtract 60 seconds from iat to account for clock drift let iat = now - 60; // GitHub allows max 10 minutes, we use 9 to be safe let exp = now + (9 * 60); let claims = GitHubAppClaims { iss: self.app_id.to_string(), iat, exp, }; let header = Header::new(Algorithm::RS256); let key = EncodingKey::from_rsa_pem(self.private_key_pem.expose_secret().as_bytes())?; encode(&header, &claims, &key).map_err(JwtError::EncodingError) } } #[cfg(test)] mod tests { use super::*; // Test with a dummy key - in real tests you'd use a proper test key #[test] fn test_invalid_base64_fails() { let result = GitHubAppJwt::new(12345, SecretString::new("not-valid-base64!!!".into())); assert!(matches!(result, Err(JwtError::Base64Error))); } #[test] fn test_invalid_pem_fails() { // Valid base64, but not a valid PEM let invalid_pem_b64 = BASE64_STANDARD.encode("not a real pem key"); let result = GitHubAppJwt::new(12345, SecretString::new(invalid_pem_b64.into())); assert!(matches!(result, Err(JwtError::InvalidPrivateKey(_)))); } } ================================================ FILE: crates/remote/src/github_app/mod.rs ================================================ mod jwt; mod pr_review; mod service; mod webhook; pub use jwt::GitHubAppJwt; pub use pr_review::{PrReviewError, PrReviewParams, PrReviewService}; pub use service::{GitHubAppService, InstallationInfo, PrDetails, PrRef, Repository}; pub use webhook::verify_webhook_signature; ================================================ FILE: crates/remote/src/github_app/pr_review.rs ================================================ //! PR Review service for webhook-triggered code reviews. use std::{fs::File, path::Path}; use flate2::{Compression, write::GzEncoder}; use reqwest::Client; use sqlx::PgPool; use tar::Builder; use thiserror::Error; use tracing::{debug, error, info}; use uuid::Uuid; use super::service::{GitHubAppError, GitHubAppService}; use crate::{ db::reviews::{CreateWebhookReviewParams, ReviewError, ReviewRepository}, r2::{R2Error, R2Service}, }; /// Parameters extracted from webhook payload for PR review #[derive(Debug, Clone)] pub struct PrReviewParams { pub installation_id: i64, pub owner: String, pub repo: String, pub pr_number: u64, pub pr_title: String, pub pr_body: String, pub head_sha: String, pub base_ref: String, // Branch name like "main" - used to calculate merge-base } #[derive(Debug, Error)] pub enum PrReviewError { #[error("GitHub error: {0}")] GitHub(#[from] GitHubAppError), #[error("R2 error: {0}")] R2(#[from] R2Error), #[error("Database error: {0}")] Database(#[from] ReviewError), #[error("Archive error: {0}")] Archive(String), #[error("Worker error: {0}")] Worker(String), } /// Service for processing webhook-triggered PR reviews pub struct PrReviewService { github_app: GitHubAppService, r2: R2Service, http_client: Client, worker_base_url: String, server_base_url: String, } impl PrReviewService { pub fn new( github_app: GitHubAppService, r2: R2Service, http_client: Client, worker_base_url: String, server_base_url: String, ) -> Self { Self { github_app, r2, http_client, worker_base_url, server_base_url, } } /// Process a PR review from webhook. /// /// This will: /// 1. Clone the repository at the PR head commit /// 2. Create a tarball of the repository /// 3. Upload the tarball to R2 /// 4. Create a review record in the database /// 5. Start the review worker /// /// Returns the review ID on success. pub async fn process_pr_review( &self, pool: &PgPool, params: PrReviewParams, ) -> Result { let review_id = Uuid::new_v4(); info!( review_id = %review_id, owner = %params.owner, repo = %params.repo, pr_number = params.pr_number, "Starting webhook PR review" ); // 1. Clone the repository let temp_dir = self .github_app .clone_repo( params.installation_id, ¶ms.owner, ¶ms.repo, ¶ms.head_sha, ) .await?; debug!(review_id = %review_id, "Repository cloned"); // 2. Calculate merge-base for accurate diff computation let base_commit = GitHubAppService::get_merge_base(temp_dir.path(), ¶ms.base_ref).await?; debug!(review_id = %review_id, base_commit = %base_commit, "Merge-base calculated"); // 3. Create tarball let source_dir = temp_dir.path().to_path_buf(); let tarball = tokio::task::spawn_blocking(move || create_tarball(&source_dir)) .await .map_err(|e| PrReviewError::Archive(format!("Tarball task failed: {e}")))? .map_err(|e| PrReviewError::Archive(e.to_string()))?; let tarball_size_mb = tarball.len() as f64 / 1_048_576.0; debug!(review_id = %review_id, size_mb = tarball_size_mb, "Tarball created"); // 4. Upload to R2 let r2_path = self.r2.upload_bytes(review_id, tarball).await?; debug!(review_id = %review_id, r2_path = %r2_path, "Uploaded to R2"); // 5. Create review record in database let gh_pr_url = format!( "https://github.com/{}/{}/pull/{}", params.owner, params.repo, params.pr_number ); let repo = ReviewRepository::new(pool); repo.create_webhook_review(CreateWebhookReviewParams { id: review_id, gh_pr_url: &gh_pr_url, r2_path: &r2_path, pr_title: ¶ms.pr_title, github_installation_id: params.installation_id, pr_owner: ¶ms.owner, pr_repo: ¶ms.repo, pr_number: params.pr_number as i32, }) .await?; debug!(review_id = %review_id, "Review record created"); // 6. Start the review worker let codebase_url = format!( "{}/reviews/{}/payload.tar.gz", self.r2_public_url(), review_id ); let callback_url = format!("{}/review/{}", self.server_base_url, review_id); let start_request = serde_json::json!({ "id": review_id.to_string(), "title": params.pr_title, "description": params.pr_body, "org": params.owner, "repo": params.repo, "codebaseUrl": codebase_url, "baseCommit": base_commit, "callbackUrl": callback_url, }); let response = self .http_client .post(format!("{}/review/start", self.worker_base_url)) .json(&start_request) .send() .await .map_err(|e| PrReviewError::Worker(format!("Failed to call worker: {e}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); error!(review_id = %review_id, status = %status, body = %body, "Worker returned error"); return Err(PrReviewError::Worker(format!( "Worker returned {}: {}", status, body ))); } info!(review_id = %review_id, "Review worker started successfully"); Ok(review_id) } /// Get the public URL for R2 (used to construct codebase URLs for the worker). /// This assumes the R2 bucket has public read access configured. fn r2_public_url(&self) -> &str { // The worker needs to be able to fetch the tarball from R2. // This is typically configured via a public bucket URL or CDN. // For now, we'll use the worker base URL as a proxy assumption. // In production, this should be configured separately. &self.worker_base_url } } /// Create a tar.gz archive from a directory fn create_tarball(source_dir: &Path) -> Result, std::io::Error> { debug!("Creating tarball from {}", source_dir.display()); let mut buffer = Vec::new(); { let encoder = GzEncoder::new(&mut buffer, Compression::default()); let mut archive = Builder::new(encoder); add_directory_to_archive(&mut archive, source_dir, source_dir)?; let encoder = archive.into_inner()?; encoder.finish()?; } debug!("Created tarball: {} bytes", buffer.len()); Ok(buffer) } fn add_directory_to_archive( archive: &mut Builder, base_dir: &Path, current_dir: &Path, ) -> Result<(), std::io::Error> { let entries = std::fs::read_dir(current_dir)?; for entry in entries { let entry = entry?; let path = entry.path(); let relative_path = path.strip_prefix(base_dir).map_err(std::io::Error::other)?; let metadata = entry.metadata()?; if metadata.is_dir() { // Recursively add directory contents add_directory_to_archive(archive, base_dir, &path)?; } else if metadata.is_file() { // Add file to archive let mut file = File::open(&path)?; archive.append_file(relative_path, &mut file)?; } // Skip symlinks and other special files } Ok(()) } ================================================ FILE: crates/remote/src/github_app/service.rs ================================================ use reqwest::Client; use secrecy::SecretString; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use thiserror::Error; use tokio::process::Command; use tracing::{debug, info, warn}; use super::jwt::{GitHubAppJwt, JwtError}; use crate::config::GitHubAppConfig; const USER_AGENT: &str = "VibeKanbanRemote/1.0"; const GITHUB_API_BASE: &str = "https://api.github.com"; #[derive(Debug, Error)] pub enum GitHubAppError { #[error("JWT error: {0}")] Jwt(#[from] JwtError), #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), #[error("GitHub API error: {status} - {message}")] Api { status: u16, message: String }, #[error("Installation not found")] InstallationNotFound, #[error("Git operation failed: {0}")] GitOperation(String), } /// Information about a GitHub App installation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstallationInfo { pub id: i64, pub account: InstallationAccount, pub repository_selection: String, // "all" or "selected" pub suspended_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstallationAccount { pub login: String, #[serde(rename = "type")] pub account_type: String, // "Organization" or "User" pub id: i64, } /// A repository accessible via an installation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Repository { pub id: i64, pub full_name: String, pub name: String, pub private: bool, } #[derive(Debug, Deserialize)] struct InstallationTokenResponse { token: String, expires_at: String, } #[derive(Debug, Deserialize)] struct RepositoriesResponse { repositories: Vec, } /// Details about a pull request #[derive(Debug, Clone, Deserialize)] pub struct PrDetails { pub title: String, pub body: Option, pub head: PrRef, pub base: PrRef, } /// A git ref (branch/commit) in a PR #[derive(Debug, Clone, Deserialize)] pub struct PrRef { pub sha: String, #[serde(rename = "ref")] pub ref_name: String, } /// Service for interacting with the GitHub App API #[derive(Clone)] pub struct GitHubAppService { jwt_generator: GitHubAppJwt, client: Client, app_slug: String, webhook_secret: SecretString, } impl GitHubAppService { pub fn new(config: &GitHubAppConfig, client: Client) -> Result { let jwt_generator = GitHubAppJwt::new(config.app_id, config.private_key.clone())?; Ok(Self { jwt_generator, client, app_slug: config.app_slug.clone(), webhook_secret: config.webhook_secret.clone(), }) } /// Get the app slug for constructing installation URLs pub fn app_slug(&self) -> &str { &self.app_slug } /// Get the webhook secret for signature verification pub fn webhook_secret(&self) -> &SecretString { &self.webhook_secret } /// Get an installation access token for making API calls on behalf of an installation pub async fn get_installation_token( &self, installation_id: i64, ) -> Result { let jwt = self.jwt_generator.generate()?; let url = format!( "{}/app/installations/{}/access_tokens", GITHUB_API_BASE, installation_id ); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", jwt)) .header("Accept", "application/vnd.github+json") .header("User-Agent", USER_AGENT) .header("X-GitHub-Api-Version", "2022-11-28") .send() .await?; if !response.status().is_success() { let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); warn!( installation_id, status, message, "Failed to get installation token" ); return Err(GitHubAppError::Api { status, message }); } let token_response: InstallationTokenResponse = response.json().await?; info!( installation_id, expires_at = %token_response.expires_at, "Got installation access token" ); Ok(token_response.token) } /// Get details about a specific installation pub async fn get_installation( &self, installation_id: i64, ) -> Result { let jwt = self.jwt_generator.generate()?; let url = format!("{}/app/installations/{}", GITHUB_API_BASE, installation_id); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", jwt)) .header("Accept", "application/vnd.github+json") .header("User-Agent", USER_AGENT) .header("X-GitHub-Api-Version", "2022-11-28") .send() .await?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Err(GitHubAppError::InstallationNotFound); } if !response.status().is_success() { let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); return Err(GitHubAppError::Api { status, message }); } let installation: InstallationInfo = response.json().await?; Ok(installation) } /// List repositories accessible to an installation (handles pagination for 100+ repos) pub async fn list_installation_repos( &self, installation_id: i64, ) -> Result, GitHubAppError> { let token = self.get_installation_token(installation_id).await?; let url = format!("{}/installation/repositories", GITHUB_API_BASE); let mut all_repos = Vec::new(); let mut page = 1u32; loop { let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/vnd.github+json") .header("User-Agent", USER_AGENT) .header("X-GitHub-Api-Version", "2022-11-28") .query(&[("per_page", "100"), ("page", &page.to_string())]) .send() .await?; if !response.status().is_success() { let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); return Err(GitHubAppError::Api { status, message }); } let repos_response: RepositoriesResponse = response.json().await?; let count = repos_response.repositories.len(); all_repos.extend(repos_response.repositories); // If we got fewer than 100, we've reached the last page if count < 100 { break; } page += 1; } Ok(all_repos) } /// Post a comment on a pull request pub async fn post_pr_comment( &self, installation_id: i64, owner: &str, repo: &str, pr_number: u64, body: &str, ) -> Result<(), GitHubAppError> { let token = self.get_installation_token(installation_id).await?; // Use the issues API to post comments (PRs are issues in GitHub) let url = format!( "{}/repos/{}/{}/issues/{}/comments", GITHUB_API_BASE, owner, repo, pr_number ); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/vnd.github+json") .header("User-Agent", USER_AGENT) .header("X-GitHub-Api-Version", "2022-11-28") .json(&serde_json::json!({ "body": body })) .send() .await?; if !response.status().is_success() { let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); warn!( owner, repo, pr_number, status, message, "Failed to post PR comment" ); return Err(GitHubAppError::Api { status, message }); } info!(owner, repo, pr_number, "Posted PR comment"); Ok(()) } /// Clone a repository using the installation token for authentication. /// /// Returns a TempDir containing the cloned repository at the specified commit. /// The TempDir will be automatically cleaned up when dropped. pub async fn clone_repo( &self, installation_id: i64, owner: &str, repo: &str, head_sha: &str, ) -> Result { let token = self.get_installation_token(installation_id).await?; // Create temp directory let temp_dir = tempfile::tempdir() .map_err(|e| GitHubAppError::GitOperation(format!("Failed to create temp dir: {e}")))?; let clone_url = format!( "https://x-access-token:{}@github.com/{}/{}.git", token, owner, repo ); debug!(owner, repo, head_sha, "Cloning repository"); // Clone the repository with security flags to prevent code execution from untrusted repos // Note: We do a full clone (not shallow) to ensure git history is available for merge-base calculation let output = Command::new("git") .args([ "-c", "core.hooksPath=/dev/null", "-c", "core.autocrlf=false", "-c", "core.symlinks=false", "clone", &clone_url, ".", ]) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(temp_dir.path()) .output() .await .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitHubAppError::GitOperation("git is not installed or not in PATH".to_string()) } else { GitHubAppError::GitOperation(format!("Failed to run git clone: {e}")) } })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Redact the token from error messages let redacted_stderr = stderr.replace(&token, "[REDACTED]"); return Err(GitHubAppError::GitOperation(format!( "git clone failed: {redacted_stderr}" ))); } // Fetch the specific commit (in case it's not in the default branch) let output = Command::new("git") .args([ "-c", "core.hooksPath=/dev/null", "fetch", "origin", head_sha, ]) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(temp_dir.path()) .output() .await .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitHubAppError::GitOperation("git is not installed or not in PATH".to_string()) } else { GitHubAppError::GitOperation(format!("Failed to run git fetch: {e}")) } })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let redacted_stderr = stderr.replace(&token, "[REDACTED]"); return Err(GitHubAppError::GitOperation(format!( "git fetch failed: {redacted_stderr}" ))); } // Checkout the specific commit let output = Command::new("git") .args(["-c", "core.hooksPath=/dev/null", "checkout", head_sha]) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_TERMINAL_PROMPT", "0") .current_dir(temp_dir.path()) .output() .await .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { GitHubAppError::GitOperation("git is not installed or not in PATH".to_string()) } else { GitHubAppError::GitOperation(format!("Failed to run git checkout: {e}")) } })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(GitHubAppError::GitOperation(format!( "git checkout failed: {stderr}" ))); } info!(owner, repo, head_sha, "Repository cloned successfully"); Ok(temp_dir) } /// Calculate the merge-base between the current HEAD and the base branch. /// This gives the correct base commit for computing diffs, even if the base branch has moved. pub async fn get_merge_base( repo_dir: &std::path::Path, base_ref: &str, ) -> Result { let output = Command::new("git") .args(["merge-base", &format!("origin/{}", base_ref), "HEAD"]) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .current_dir(repo_dir) .output() .await .map_err(|e| GitHubAppError::GitOperation(format!("merge-base failed: {e}")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(GitHubAppError::GitOperation(format!( "merge-base failed: {stderr}" ))); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Get details about a pull request pub async fn get_pr_details( &self, installation_id: i64, owner: &str, repo: &str, pr_number: u64, ) -> Result { let token = self.get_installation_token(installation_id).await?; let url = format!( "{}/repos/{}/{}/pulls/{}", GITHUB_API_BASE, owner, repo, pr_number ); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/vnd.github+json") .header("User-Agent", USER_AGENT) .header("X-GitHub-Api-Version", "2022-11-28") .send() .await?; if !response.status().is_success() { let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); return Err(GitHubAppError::Api { status, message }); } let pr: PrDetails = response.json().await?; Ok(pr) } } ================================================ FILE: crates/remote/src/github_app/webhook.rs ================================================ use hmac::{Hmac, Mac}; use sha2::Sha256; use subtle::ConstantTimeEq; type HmacSha256 = Hmac; /// Verify a GitHub webhook signature. /// /// GitHub sends the HMAC-SHA256 signature in the `X-Hub-Signature-256` header /// in the format `sha256=`. /// /// Returns true if the signature is valid. pub fn verify_webhook_signature(secret: &[u8], signature_header: &str, payload: &[u8]) -> bool { // Extract the hex signature from the header let Some(hex_signature) = signature_header.strip_prefix("sha256=") else { return false; }; // Decode the hex signature let Ok(expected_signature) = hex::decode(hex_signature) else { return false; }; // Compute HMAC-SHA256 let Ok(mut mac) = HmacSha256::new_from_slice(secret) else { return false; }; mac.update(payload); let computed_signature = mac.finalize().into_bytes(); // Constant-time comparison to prevent timing attacks computed_signature[..].ct_eq(&expected_signature).into() } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_signature() { let secret = b"test-secret"; let payload = b"test payload"; // Compute expected signature let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(payload); let signature = mac.finalize().into_bytes(); let signature_header = format!("sha256={}", hex::encode(signature)); assert!(verify_webhook_signature(secret, &signature_header, payload)); } #[test] fn test_invalid_signature() { let secret = b"test-secret"; let payload = b"test payload"; let wrong_signature = "sha256=0000000000000000000000000000000000000000000000000000000000000000"; assert!(!verify_webhook_signature(secret, wrong_signature, payload)); } #[test] fn test_missing_prefix() { let secret = b"test-secret"; let payload = b"test payload"; let no_prefix = "0000000000000000000000000000000000000000000000000000000000000000"; assert!(!verify_webhook_signature(secret, no_prefix, payload)); } #[test] fn test_invalid_hex() { let secret = b"test-secret"; let payload = b"test payload"; let invalid_hex = "sha256=not-valid-hex"; assert!(!verify_webhook_signature(secret, invalid_hex, payload)); } } ================================================ FILE: crates/remote/src/lib.rs ================================================ mod analytics; mod app; pub mod attachments; pub mod audit; mod auth; pub mod azure_blob; mod billing; pub mod config; pub mod db; pub mod digest; pub mod github_app; pub mod mail; mod middleware; pub mod mutation_definition; pub mod notifications; pub mod r2; pub mod routes; pub mod shape_definition; pub mod shape_route; pub mod shape_routes; pub mod shapes; mod shared_key_auth; mod state; use std::env; pub use app::Server; pub use billing::{BillingCheckError, BillingService}; use opentelemetry::trace::TracerProvider as _; pub use state::AppState; use tracing_error::ErrorLayer; use tracing_subscriber::{ Layer, fmt::{self, format::FmtSpan}, layer::SubscriberExt, util::SubscriberInitExt, }; pub use utils::sentry::{SentrySource, init_once as sentry_init_once}; fn init_otel_layer() -> Option + Send + Sync>> where S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span> + Send + Sync, { let connection_string = env::var("APPLICATIONINSIGHTS_CONNECTION_STRING").ok()?; if connection_string.is_empty() { return None; } // Create the background client using std::thread::spawn. // https://github.com/frigus02/opentelemetry-application-insights/blob/6d3ac4505c0c47e448bb8de4ac67d904f8eacb76/src/lib.rs#L168 let http_client = std::thread::spawn(otel_reqwest::blocking::Client::new) .join() .ok()?; let exporter = opentelemetry_application_insights::Exporter::new_from_connection_string( &connection_string, http_client, ) .ok()?; let service_name = env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "vibe-kanban-remote".to_string()); let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_resource( opentelemetry_sdk::Resource::builder() .with_service_name(service_name) .build(), ) .with_batch_exporter(exporter) .build(); // Register globally so the provider outlives this function. // Without this, Drop shuts down the batch exporter and no spans export. opentelemetry::global::set_tracer_provider(provider.clone()); let tracer = provider.tracer("vibe-kanban-remote"); let layer = tracing_opentelemetry::OpenTelemetryLayer::new(tracer); Some(layer.boxed()) } pub fn init_tracing() { if tracing::dispatcher::has_been_set() { return; } let env_filter = env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".to_string()); let fmt_layer = fmt::layer() .json() .with_target(false) .with_span_events(FmtSpan::CLOSE) .boxed(); let otel_layer = init_otel_layer(); let otel_enabled = otel_layer.is_some(); tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new(env_filter)) .with(ErrorLayer::default()) .with(fmt_layer) .with(otel_layer) .with(utils::sentry::sentry_layer()) .init(); tracing::info!( otel_enabled, "Tracing initialized ({})", if otel_enabled { "stdout + Application Insights" } else { "stdout only" } ); } pub fn configure_user_scope(user_id: uuid::Uuid, username: Option<&str>, email: Option<&str>) { utils::sentry::configure_user_scope(&user_id.to_string(), username, email); } ================================================ FILE: crates/remote/src/mail.rs ================================================ use std::time::Duration; use api_types::MemberRole; use async_trait::async_trait; use serde_json::json; use crate::digest::DigestError; const LOOPS_INVITE_TEMPLATE_ID: &str = "cmhvy2wgs3s13z70i1pxakij9"; const LOOPS_REVIEW_READY_TEMPLATE_ID: &str = "cmj47k5ge16990iylued9by17"; const LOOPS_REVIEW_FAILED_TEMPLATE_ID: &str = "cmj49ougk1c8s0iznavijdqpo"; pub const DIGEST_PREVIEW_COUNT: usize = 5; #[derive(Debug, Clone)] pub struct DigestContact<'a> { pub email: &'a str, pub user_id: &'a str, pub first_name: Option<&'a str>, pub last_name: Option<&'a str>, } #[derive(Debug, Clone)] pub struct DigestNotificationItem { pub title: String, pub body: String, pub url: String, } #[async_trait] pub trait Mailer: Send + Sync { async fn send_org_invitation( &self, org_name: &str, email: &str, accept_url: &str, role: MemberRole, invited_by: Option<&str>, ); async fn send_review_ready(&self, email: &str, review_url: &str, pr_name: &str); async fn send_review_failed(&self, email: &str, pr_name: &str, review_id: &str); async fn send_digest_event( &self, contact: &DigestContact<'_>, notification_count: i32, items: &[DigestNotificationItem], notifications_url: &str, ) -> Result<(), DigestError>; } /// No-op mailer used when `LOOPS_EMAIL_API_KEY` is not configured. pub struct NoopMailer; #[async_trait] impl Mailer for NoopMailer { async fn send_org_invitation( &self, org_name: &str, email: &str, _accept_url: &str, _role: MemberRole, _invited_by: Option<&str>, ) { tracing::warn!( email = %email, org_name = %org_name, "Email service not configured — skipping org invitation email. Set LOOPS_EMAIL_API_KEY to enable." ); } async fn send_review_ready(&self, email: &str, _review_url: &str, pr_name: &str) { tracing::warn!( email = %email, pr_name = %pr_name, "Email service not configured — skipping review ready email. Set LOOPS_EMAIL_API_KEY to enable." ); } async fn send_review_failed(&self, email: &str, pr_name: &str, _review_id: &str) { tracing::warn!( email = %email, pr_name = %pr_name, "Email service not configured — skipping review failed email. Set LOOPS_EMAIL_API_KEY to enable." ); } async fn send_digest_event( &self, contact: &DigestContact<'_>, notification_count: i32, _items: &[DigestNotificationItem], _notifications_url: &str, ) -> Result<(), DigestError> { tracing::warn!( email = %contact.email, notification_count, "Email service not configured — skipping digest event. Set LOOPS_EMAIL_API_KEY to enable." ); Ok(()) } } pub struct LoopsMailer { client: reqwest::Client, api_key: String, } impl LoopsMailer { pub fn new(api_key: String) -> Self { let client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .expect("failed to build reqwest client"); Self { client, api_key } } } #[async_trait] impl Mailer for LoopsMailer { async fn send_org_invitation( &self, org_name: &str, email: &str, accept_url: &str, role: MemberRole, invited_by: Option<&str>, ) { let role_str = match role { MemberRole::Admin => "admin", MemberRole::Member => "member", }; let inviter = invited_by.unwrap_or("someone"); if cfg!(debug_assertions) { tracing::info!( "Sending invitation email to {email}\n\ Organization: {org_name}\n\ Role: {role_str}\n\ Invited by: {inviter}\n\ Accept URL: {accept_url}" ); } let payload = json!({ "transactionalId": LOOPS_INVITE_TEMPLATE_ID, "email": email, "dataVariables": { "org_name": org_name, "accept_url": accept_url, "invited_by": inviter, } }); let res = self .client .post("https://app.loops.so/api/v1/transactional") .bearer_auth(&self.api_key) .json(&payload) .send() .await; match res { Ok(resp) if resp.status().is_success() => { tracing::debug!("Invitation email sent via Loops to {email}"); } Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::warn!(status = %status, body = %body, "Loops send failed"); } Err(err) => { tracing::error!(error = ?err, "Loops request error"); } } } async fn send_review_ready(&self, email: &str, review_url: &str, pr_name: &str) { if cfg!(debug_assertions) { tracing::info!( "Sending review ready email to {email}\n\ PR: {pr_name}\n\ Review URL: {review_url}" ); } let payload = json!({ "transactionalId": LOOPS_REVIEW_READY_TEMPLATE_ID, "email": email, "dataVariables": { "review_url": review_url, "pr_name": pr_name, } }); let res = self .client .post("https://app.loops.so/api/v1/transactional") .bearer_auth(&self.api_key) .json(&payload) .send() .await; match res { Ok(resp) if resp.status().is_success() => { tracing::debug!("Review ready email sent via Loops to {email}"); } Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::warn!(status = %status, body = %body, "Loops send failed for review ready"); } Err(err) => { tracing::error!(error = ?err, "Loops request error for review ready"); } } } async fn send_review_failed(&self, email: &str, pr_name: &str, review_id: &str) { if cfg!(debug_assertions) { tracing::info!( "Sending review failed email to {email}\n\ PR: {pr_name}\n\ Review ID: {review_id}" ); } let payload = json!({ "transactionalId": LOOPS_REVIEW_FAILED_TEMPLATE_ID, "email": email, "dataVariables": { "pr_name": pr_name, "review_id": review_id, } }); let res = self .client .post("https://app.loops.so/api/v1/transactional") .bearer_auth(&self.api_key) .json(&payload) .send() .await; match res { Ok(resp) if resp.status().is_success() => { tracing::debug!("Review failed email sent via Loops to {email}"); } Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::warn!(status = %status, body = %body, "Loops send failed for review failed"); } Err(err) => { tracing::error!(error = ?err, "Loops request error for review failed"); } } } async fn send_digest_event( &self, contact: &DigestContact<'_>, notification_count: i32, items: &[DigestNotificationItem], notifications_url: &str, ) -> Result<(), DigestError> { if cfg!(debug_assertions) { tracing::info!( "Firing sendDigest event for {}\n\ User ID: {}\n\ First name: {:?}\n\ Last name: {:?}\n\ Total notifications: {notification_count}\n\ Items: {}\n\ Notifications URL: {notifications_url}", contact.email, contact.user_id, contact.first_name, contact.last_name, items.len() ); } let mut event_properties = serde_json::Map::new(); event_properties.insert("notificationCount".into(), json!(notification_count)); event_properties.insert("notificationsUrl".into(), json!(notifications_url)); for (i, item) in items.iter().take(DIGEST_PREVIEW_COUNT).enumerate() { event_properties.insert(format!("notification{i}Title"), json!(item.title)); event_properties.insert(format!("notification{i}Body"), json!(item.body)); event_properties.insert(format!("notification{i}Url"), json!(item.url)); } let mut payload = json!({ "email": contact.email, "userId": contact.user_id, "eventName": "sendDigest", "eventProperties": event_properties, }); if let Some(first_name) = contact.first_name { payload["firstName"] = json!(first_name); } if let Some(last_name) = contact.last_name { payload["lastName"] = json!(last_name); } let res = self .client .post("https://app.loops.so/api/v1/events/send") .bearer_auth(&self.api_key) .json(&payload) .send() .await; match res { Ok(resp) if resp.status().is_success() => { tracing::debug!("Digest event fired via Loops for {}", contact.email); Ok(()) } Ok(resp) => { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); Err(DigestError::LoopsSendFailed { status, body }) } Err(err) => Err(DigestError::LoopsRequest(err)), } } } ================================================ FILE: crates/remote/src/main.rs ================================================ use remote::{ BillingService, SentrySource, Server, config::RemoteServerConfig, init_tracing, sentry_init_once, }; #[tokio::main] async fn main() -> anyhow::Result<()> { // Install rustls crypto provider before any TLS operations rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); sentry_init_once(SentrySource::Remote); init_tracing(); let config = RemoteServerConfig::from_env()?; #[cfg(feature = "vk-billing")] let billing = { use std::sync::Arc; use billing::{BillingConfig, BillingProvider, StripeBillingProvider}; use remote::db; match BillingConfig::from_env()? { Some(billing_config) => { let pool = db::create_pool(&config.database_url).await?; let provider: Arc = Arc::new(StripeBillingProvider::new( pool, billing_config.stripe_secret_key, billing_config.stripe_price_id, billing_config.stripe_webhook_secret, Some(billing_config.free_seat_limit), )); BillingService::new(Some(provider)) } None => BillingService::new(None), } }; #[cfg(not(feature = "vk-billing"))] let billing = BillingService::new(); Server::run(config, billing).await } ================================================ FILE: crates/remote/src/middleware/mod.rs ================================================ pub mod version; ================================================ FILE: crates/remote/src/middleware/version.rs ================================================ use axum::{ body::Body, http::{Request, header::HeaderValue}, middleware::Next, response::Response, }; pub async fn add_version_headers(request: Request, next: Next) -> Response { let mut response = next.run(request).await; response.headers_mut().insert( "X-Server-Version", HeaderValue::from_static(env!("CARGO_PKG_VERSION")), ); response } ================================================ FILE: crates/remote/src/mutation_definition.rs ================================================ //! Mutation definition builder for type-safe route and metadata generation. //! //! This module provides `MutationBuilder`, a builder that: //! - Generates axum routers for CRUD mutation routes //! - Captures type information for TypeScript generation //! - Uses `HasJsonPayload` to ensure handler signatures match declared C/U types //! //! # Example //! //! ```ignore //! use crate::mutation_definition::MutationBuilder; //! //! pub fn mutation() -> MutationBuilder { //! MutationBuilder::new("tags") //! .list(list_tags) //! .get(get_tag) //! .create(create_tag) //! .update(update_tag) //! .delete(delete_tag) //! } //! //! pub fn router() -> Router { //! mutation().router() //! } //! ``` use std::marker::PhantomData; use axum::{Json, handler::Handler, routing::MethodRouter}; use ts_rs::TS; use crate::AppState; type MutationMarker = fn() -> (E, C, U); // ============================================================================= // HasJsonPayload - Structural trait linking handlers to their payload types // ============================================================================= /// Marker trait implemented for extractor tuples that include `Json` as payload. /// /// This links MutationBuilder's `C`/`U` generic arguments to the actual handler payload /// type and prevents metadata drift from handler signatures. pub trait HasJsonPayload {} impl HasJsonPayload for (Json,) {} impl HasJsonPayload for (A, Json) {} impl HasJsonPayload for (A, B, Json) {} impl HasJsonPayload for (A, B, C, Json) {} impl HasJsonPayload for (A, B, C, D, Json) {} impl HasJsonPayload for (A, B, C, D, E0, Json) {} impl HasJsonPayload for (A, B, C, D, E0, F, Json) {} impl HasJsonPayload for (A, B, C, D, E0, F, G, Json) {} impl HasJsonPayload for (A, B, C, D, E0, F, G, H, Json) {} // ============================================================================= // MutationDefinition - Metadata for TypeScript generation // ============================================================================= /// Metadata extracted from a MutationBuilder for TypeScript code generation. #[derive(Debug)] pub struct MutationDefinition { pub table: &'static str, pub row_type: String, pub create_type: Option, pub update_type: Option, } // ============================================================================= // MutationBuilder Builder // ============================================================================= /// Builder for mutation routes and metadata. /// /// Type parameters: /// - `E`: The row type (e.g., `Tag`) /// - `C`: The create request type, or `NoCreate` if no create /// - `U`: The update request type, or `NoUpdate` if no update pub struct MutationBuilder { table: &'static str, base_route: MethodRouter, id_route: MethodRouter, _phantom: PhantomData>, } impl MutationBuilder { /// Create a new MutationBuilder for the given table. pub fn new(table: &'static str) -> Self { Self { table, base_route: MethodRouter::new(), id_route: MethodRouter::new(), _phantom: PhantomData, } } } impl MutationBuilder { /// Add a list handler (GET /{table}). pub fn list(mut self, handler: H) -> Self where H: Handler + Clone + Send + 'static, T: 'static, { self.base_route = self.base_route.get(handler); self } /// Add a get handler (GET /{table}/{id}). pub fn get(mut self, handler: H) -> Self where H: Handler + Clone + Send + 'static, T: 'static, { self.id_route = self.id_route.get(handler); self } /// Add a delete handler (DELETE /{table}/{id}). pub fn delete(mut self, handler: H) -> Self where H: Handler + Clone + Send + 'static, T: 'static, { self.id_route = self.id_route.delete(handler); self } /// Build the axum router from the registered handlers. pub fn router(self) -> axum::Router { let base_path = format!("/{}", self.table); let id_path = format!("/{}/{{id}}", self.table); axum::Router::new() .route(&base_path, self.base_route) .route(&id_path, self.id_route) } } impl MutationBuilder { /// Add a create handler (POST /{table}). /// /// The handler's extractor tuple must contain `Json`, ensuring the /// declared create type matches what the handler actually accepts. pub fn create(self, handler: H) -> MutationBuilder where C: TS, H: Handler + Clone + Send + 'static, T: HasJsonPayload + 'static, { MutationBuilder { table: self.table, base_route: self.base_route.post(handler), id_route: self.id_route, _phantom: PhantomData, } } } impl MutationBuilder { /// Add an update handler (PATCH /{table}/{id}). /// /// The handler's extractor tuple must contain `Json`, ensuring the /// declared update type matches what the handler actually accepts. pub fn update(self, handler: H) -> MutationBuilder where U: TS, H: Handler + Clone + Send + 'static, T: HasJsonPayload + 'static, { MutationBuilder { table: self.table, base_route: self.base_route, id_route: self.id_route.patch(handler), _phantom: PhantomData, } } } /// Marker type for mutations without a create endpoint. pub struct NoCreate; /// Marker type for mutations without an update endpoint. pub struct NoUpdate; // Metadata extraction — one impl per combination of NoCreate/NoUpdate vs real types. impl MutationBuilder { pub fn definition(&self) -> MutationDefinition { MutationDefinition { table: self.table, row_type: E::name(), create_type: Some(C::name()), update_type: Some(U::name()), } } } impl MutationBuilder { pub fn definition(&self) -> MutationDefinition { MutationDefinition { table: self.table, row_type: E::name(), create_type: None, update_type: Some(U::name()), } } } impl MutationBuilder { pub fn definition(&self) -> MutationDefinition { MutationDefinition { table: self.table, row_type: E::name(), create_type: Some(C::name()), update_type: None, } } } impl MutationBuilder { pub fn definition(&self) -> MutationDefinition { MutationDefinition { table: self.table, row_type: E::name(), create_type: None, update_type: None, } } } ================================================ FILE: crates/remote/src/notifications.rs ================================================ use std::collections::HashSet; use api_types::{Issue, NotificationPayload, NotificationType}; use sqlx::PgPool; use uuid::Uuid; use crate::db::{ issue_assignees::IssueAssigneeRepository, issue_followers::IssueFollowerRepository, notifications::NotificationRepository, organization_members::is_member, }; pub async fn notify_issue_subscribers( pool: &PgPool, organization_id: Uuid, actor_user_id: Uuid, issue: &Issue, notification_type: NotificationType, extra_payload: NotificationPayload, comment_id: Option, ) { let recipients = match collect_issue_recipients(pool, organization_id, issue.id, actor_user_id) .await { Ok(r) => r, Err(e) => { tracing::warn!(?e, issue_id = %issue.id, "failed to collect notification recipients"); return; } }; send_issue_notifications( pool, organization_id, actor_user_id, &recipients, issue, notification_type, extra_payload, comment_id, Some(issue.id), ) .await; } /// Like `notify_issue_subscribers` but with pre-collected recipients. /// Use when recipients must be gathered before an operation (e.g. delete) but /// notifications should only be sent after it succeeds. #[allow(clippy::too_many_arguments)] pub async fn send_issue_notifications( pool: &PgPool, organization_id: Uuid, actor_user_id: Uuid, recipients: &[Uuid], issue: &Issue, notification_type: NotificationType, extra_payload: NotificationPayload, comment_id: Option, issue_id: Option, ) { if recipients.is_empty() { return; } let payload = build_payload(issue, actor_user_id, notification_type, extra_payload); for &recipient_id in recipients { if let Err(e) = NotificationRepository::create( pool, organization_id, recipient_id, notification_type, payload.clone(), issue_id, comment_id, ) .await { tracing::warn!(?e, %recipient_id, issue_id = %issue.id, "failed to create notification"); } } } #[allow(clippy::too_many_arguments)] pub async fn send_debounced_issue_notifications( pool: &PgPool, organization_id: Uuid, actor_user_id: Uuid, recipients: &[Uuid], issue: &Issue, notification_type: NotificationType, extra_payload: NotificationPayload, comment_id: Option, issue_id: Option, ) { if recipients.is_empty() { return; } let payload = build_payload(issue, actor_user_id, notification_type, extra_payload); for &recipient_id in recipients { if let Err(e) = NotificationRepository::upsert_recent( pool, organization_id, recipient_id, notification_type, payload.clone(), issue_id, comment_id, ) .await { tracing::warn!(?e, %recipient_id, issue_id = %issue.id, "failed to upsert notification"); } } } pub async fn notify_user( pool: &PgPool, organization_id: Uuid, actor_user_id: Uuid, recipient_user_id: Uuid, issue: &Issue, notification_type: NotificationType, extra_payload: NotificationPayload, ) { if !is_member(pool, organization_id, recipient_user_id) .await .unwrap_or(false) { return; } send_issue_notifications( pool, organization_id, actor_user_id, &[recipient_user_id], issue, notification_type, extra_payload, None, Some(issue.id), ) .await; } pub async fn collect_issue_recipients( pool: &PgPool, organization_id: Uuid, issue_id: Uuid, exclude_user_id: Uuid, ) -> Result, Box> { let assignees = IssueAssigneeRepository::list_by_issue(pool, issue_id).await?; let followers = IssueFollowerRepository::list_by_issue(pool, issue_id).await?; let mut user_ids: HashSet = assignees.iter().map(|a| a.user_id).collect(); user_ids.extend(followers.iter().map(|f| f.user_id)); user_ids.remove(&exclude_user_id); let mut recipients = Vec::with_capacity(user_ids.len()); for user_id in user_ids { if is_member(pool, organization_id, user_id) .await .unwrap_or(false) { recipients.push(user_id); } } Ok(recipients) } fn build_payload( issue: &Issue, actor_user_id: Uuid, notification_type: NotificationType, extra_payload: NotificationPayload, ) -> NotificationPayload { let deeplink_path = match notification_type { NotificationType::IssueDeleted => format!("/projects/{}", issue.project_id), _ => format!("/projects/{}/issues/{}", issue.project_id, issue.id), }; NotificationPayload { deeplink_path: Some(deeplink_path), issue_id: Some(issue.id), issue_simple_id: Some(issue.simple_id.clone()), issue_title: Some(issue.title.clone()), actor_user_id: Some(actor_user_id), comment_preview: extra_payload.comment_preview, old_status_id: extra_payload.old_status_id, new_status_id: extra_payload.new_status_id, old_status_name: extra_payload.old_status_name, new_status_name: extra_payload.new_status_name, new_title: extra_payload.new_title, old_priority: extra_payload.old_priority, new_priority: extra_payload.new_priority, assignee_user_id: extra_payload.assignee_user_id, emoji: extra_payload.emoji, } } ================================================ FILE: crates/remote/src/r2.rs ================================================ use std::time::Duration; use aws_credential_types::Credentials; use aws_sdk_s3::{ Client, config::{Builder as S3ConfigBuilder, IdentityCache}, presigning::PresigningConfig, primitives::ByteStream, }; use chrono::{DateTime, Utc}; use secrecy::ExposeSecret; use uuid::Uuid; use crate::config::R2Config; /// Well-known filename for the payload tarball stored in each review folder. pub const PAYLOAD_FILENAME: &str = "payload.tar.gz"; #[derive(Clone)] pub struct R2Service { client: Client, bucket: String, presign_expiry: Duration, } #[derive(Debug)] pub struct PresignedUpload { pub upload_url: String, pub object_key: String, /// Folder path in R2 (e.g., "reviews/{review_id}") - this is stored in the database. pub folder_path: String, pub expires_at: DateTime, } #[derive(Debug, thiserror::Error)] pub enum R2Error { #[error("presign config error: {0}")] PresignConfig(String), #[error("presign error: {0}")] Presign(String), #[error("upload error: {0}")] Upload(String), } impl R2Service { pub fn new(config: &R2Config) -> Self { let credentials = Credentials::new( &config.access_key_id, config.secret_access_key.expose_secret(), None, None, "r2-static", ); let s3_config = S3ConfigBuilder::new() .region(aws_sdk_s3::config::Region::new("auto")) .endpoint_url(&config.endpoint) .credentials_provider(credentials) .force_path_style(true) .stalled_stream_protection( aws_sdk_s3::config::StalledStreamProtectionConfig::disabled(), ) .identity_cache(IdentityCache::no_cache()) .build(); let client = Client::from_conf(s3_config); Self { client, bucket: config.bucket.clone(), presign_expiry: Duration::from_secs(config.presign_expiry_secs), } } pub async fn create_presigned_upload( &self, review_id: Uuid, content_type: Option<&str>, ) -> Result { let folder_path = format!("reviews/{review_id}"); let object_key = format!("{folder_path}/{PAYLOAD_FILENAME}"); let presigning_config = PresigningConfig::builder() .expires_in(self.presign_expiry) .build() .map_err(|e| R2Error::PresignConfig(e.to_string()))?; let mut request = self .client .put_object() .bucket(&self.bucket) .key(&object_key); if let Some(ct) = content_type { request = request.content_type(ct); } let presigned = request .presigned(presigning_config) .await .map_err(|e| R2Error::Presign(e.to_string()))?; let expires_at = Utc::now() + chrono::Duration::from_std(self.presign_expiry).unwrap_or(chrono::Duration::hours(1)); Ok(PresignedUpload { upload_url: presigned.uri().to_string(), object_key, folder_path, expires_at, }) } /// Upload bytes directly to R2 (for server-side uploads). /// /// Returns the folder path (e.g., "reviews/{review_id}") to store in the database. pub async fn upload_bytes(&self, review_id: Uuid, data: Vec) -> Result { let folder_path = format!("reviews/{review_id}"); let object_key = format!("{folder_path}/{PAYLOAD_FILENAME}"); self.client .put_object() .bucket(&self.bucket) .key(&object_key) .body(ByteStream::from(data)) .content_type("application/gzip") .send() .await .map_err(|e| R2Error::Upload(e.to_string()))?; Ok(folder_path) } } ================================================ FILE: crates/remote/src/routes/attachments.rs ================================================ use api_types::{ AttachmentUrlResponse, AttachmentWithBlob, AttachmentWithUrl, ListAttachmentsResponse, }; use axum::{ Json, Router, extract::{Extension, Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, post}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::instrument; use ts_rs::TS; use uuid::Uuid; use super::organization_members::{ ensure_comment_access, ensure_issue_access, ensure_project_access, }; use crate::{ AppState, attachments::thumbnail::ThumbnailService, auth::RequestContext, azure_blob::AzureBlobError, db::{ attachments::{AttachmentError, AttachmentRepository}, blobs::{BlobError, BlobRepository}, pending_uploads::{PendingUploadError, PendingUploadRepository}, }, }; pub fn router() -> Router { Router::new() .route("/attachments/init", post(init_upload)) .route("/attachments/confirm", post(confirm_upload)) .route("/attachments/{id}/file", get(get_attachment_file)) .route("/attachments/{id}/thumbnail", get(get_attachment_thumbnail)) .route("/attachments/{id}", delete(delete_attachment)) .route( "/issues/{issue_id}/attachments", get(list_issue_attachments), ) .route( "/issues/{issue_id}/attachments/commit", post(commit_issue_attachments), ) .route( "/comments/{comment_id}/attachments", get(list_comment_attachments), ) .route( "/comments/{comment_id}/attachments/commit", post(commit_comment_attachments), ) } #[derive(Debug, Serialize, Deserialize, TS)] pub struct InitUploadRequest { pub project_id: Uuid, pub filename: String, #[ts(type = "number")] pub size_bytes: i64, pub hash: String, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct InitUploadResponse { pub upload_url: String, pub upload_id: Uuid, pub expires_at: DateTime, pub skip_upload: bool, pub existing_blob_id: Option, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct ConfirmUploadRequest { pub project_id: Uuid, pub upload_id: Uuid, pub filename: String, #[ts(optional)] pub content_type: Option, #[ts(type = "number")] pub size_bytes: i64, pub hash: String, #[ts(optional)] pub issue_id: Option, #[ts(optional)] pub comment_id: Option, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CommitAttachmentsRequest { pub attachment_ids: Vec, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CommitAttachmentsResponse { pub attachments: Vec, } #[derive(Debug, thiserror::Error)] pub enum RouteError { #[error("Azure Blob storage not configured")] NotConfigured, #[error("Azure Blob error: {0}")] AzureBlob(#[from] AzureBlobError), #[error("attachment error: {0}")] Attachment(#[from] AttachmentError), #[error("blob error: {0}")] Blob(#[from] BlobError), #[error("attachment not found")] NotFound, #[error("no thumbnail available")] NoThumbnail, #[error("access denied")] AccessDenied, #[error("file too large (max 20MB)")] FileTooLarge, #[error("upload not found or expired")] UploadNotFound, #[error("pending upload error: {0}")] PendingUpload(#[from] PendingUploadError), #[error("thumbnail generation failed: {0}")] ThumbnailError(String), } impl IntoResponse for RouteError { fn into_response(self) -> Response { let (status, message) = match &self { RouteError::NotConfigured => ( StatusCode::SERVICE_UNAVAILABLE, "Attachment storage not available", ), RouteError::AzureBlob(e) => { tracing::error!(error = %e, "Azure Blob error"); (StatusCode::INTERNAL_SERVER_ERROR, "Storage error") } RouteError::Attachment(e) => { tracing::error!(error = %e, "Attachment error"); (StatusCode::INTERNAL_SERVER_ERROR, "Database error") } RouteError::Blob(e) => { tracing::error!(error = %e, "Blob error"); (StatusCode::INTERNAL_SERVER_ERROR, "Database error") } RouteError::NotFound => (StatusCode::NOT_FOUND, "Attachment not found"), RouteError::NoThumbnail => (StatusCode::NOT_FOUND, "No thumbnail available"), RouteError::AccessDenied => (StatusCode::FORBIDDEN, "Access denied"), RouteError::FileTooLarge => { (StatusCode::PAYLOAD_TOO_LARGE, "File too large (max 20MB)") } RouteError::UploadNotFound => (StatusCode::NOT_FOUND, "Upload not found or expired"), RouteError::PendingUpload(e) => { tracing::error!(error = %e, "Pending upload error"); (StatusCode::INTERNAL_SERVER_ERROR, "Database error") } RouteError::ThumbnailError(e) => { tracing::error!(error = %e, "Thumbnail generation failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Thumbnail generation failed", ) } }; let body = serde_json::json!({ "error": message }); (status, Json(body)).into_response() } } const MAX_FILE_SIZE: i64 = 20 * 1024 * 1024; #[instrument(name = "attachments.init_upload", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id))] async fn init_upload( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, RouteError> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id) .await .map_err(|_| RouteError::AccessDenied)?; if payload.size_bytes > MAX_FILE_SIZE { return Err(RouteError::FileTooLarge); } if let Some(existing) = BlobRepository::find_by_hash(state.pool(), payload.project_id, &payload.hash).await? { let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; let read_url = azure.create_read_url(&existing.blob_path)?; return Ok(Json(InitUploadResponse { upload_url: read_url, upload_id: existing.id, expires_at: Utc::now() + chrono::Duration::minutes(5), skip_upload: true, existing_blob_id: Some(existing.id), })); } let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; let sanitized_filename = sanitize_filename(&payload.filename); let blob_path = format!( "attachments/{}/{}_{}", payload.project_id, Uuid::new_v4(), sanitized_filename ); let upload = azure.create_upload_url(&blob_path)?; let pending = PendingUploadRepository::create( state.pool(), payload.project_id, upload.blob_path, payload.hash.clone(), upload.expires_at, ) .await?; Ok(Json(InitUploadResponse { upload_url: upload.upload_url, upload_id: pending.id, expires_at: upload.expires_at, skip_upload: false, existing_blob_id: None, })) } #[instrument(name = "attachments.confirm_upload", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id))] async fn confirm_upload( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, RouteError> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id) .await .map_err(|_| RouteError::AccessDenied)?; if let Some(issue_id) = payload.issue_id { ensure_issue_access(state.pool(), ctx.user.id, issue_id) .await .map_err(|_| RouteError::AccessDenied)?; } if let Some(comment_id) = payload.comment_id { ensure_comment_access(state.pool(), ctx.user.id, comment_id) .await .map_err(|_| RouteError::AccessDenied)?; } let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; let blob = if let Some(existing) = BlobRepository::find_by_hash(state.pool(), payload.project_id, &payload.hash).await? { existing } else { let pending = PendingUploadRepository::find_by_id(state.pool(), payload.upload_id) .await? .ok_or(RouteError::UploadNotFound)?; let blob_path = &pending.blob_path; let props = azure.get_blob_properties(blob_path).await?; if props.content_length > MAX_FILE_SIZE { let _ = azure.delete_blob(blob_path).await; return Err(RouteError::FileTooLarge); } let blob_data = azure.download_blob(blob_path).await?; let thumbnail_result = ThumbnailService::generate(&blob_data, payload.content_type.as_deref()) .map_err(|e| RouteError::ThumbnailError(e.to_string()))?; let _ = PendingUploadRepository::delete(state.pool(), pending.id).await; let (thumbnail_blob_path, width, height) = match thumbnail_result { Some(thumb) => { let thumb_path = format!("thumbnails/{}", blob_path); azure .upload_blob(&thumb_path, thumb.bytes, thumb.mime_type) .await?; ( Some(thumb_path), Some(thumb.original_width as i32), Some(thumb.original_height as i32), ) } None => (None, None, None), }; BlobRepository::create( state.pool(), None, payload.project_id, blob_path.clone(), thumbnail_blob_path, payload.filename.clone(), payload.content_type.clone(), payload.size_bytes, payload.hash.clone(), width, height, ) .await? }; let expires_at = if payload.issue_id.is_some() || payload.comment_id.is_some() { None } else { Some(Utc::now() + chrono::Duration::hours(24)) }; let attachment = AttachmentRepository::create( state.pool(), None, blob.id, payload.issue_id, payload.comment_id, expires_at, ) .await?; let result = AttachmentRepository::find_by_id_with_blob(state.pool(), attachment.id) .await? .ok_or(RouteError::NotFound)?; Ok(Json(result)) } #[instrument(name = "attachments.commit_issue", skip(state, ctx, payload), fields(issue_id = %issue_id, user_id = %ctx.user.id))] async fn commit_issue_attachments( State(state): State, Extension(ctx): Extension, Path(issue_id): Path, Json(payload): Json, ) -> Result, RouteError> { ensure_issue_access(state.pool(), ctx.user.id, issue_id) .await .map_err(|_| RouteError::AccessDenied)?; let attachments = AttachmentRepository::commit_to_issue(state.pool(), &payload.attachment_ids, issue_id) .await?; Ok(Json(CommitAttachmentsResponse { attachments })) } #[instrument(name = "attachments.commit_comment", skip(state, ctx, payload), fields(comment_id = %comment_id, user_id = %ctx.user.id))] async fn commit_comment_attachments( State(state): State, Extension(ctx): Extension, Path(comment_id): Path, Json(payload): Json, ) -> Result, RouteError> { ensure_comment_access(state.pool(), ctx.user.id, comment_id) .await .map_err(|_| RouteError::AccessDenied)?; let attachments = AttachmentRepository::commit_to_comment(state.pool(), &payload.attachment_ids, comment_id) .await?; Ok(Json(CommitAttachmentsResponse { attachments })) } #[instrument(name = "attachments.list_issue", skip(state, ctx), fields(issue_id = %issue_id, user_id = %ctx.user.id))] async fn list_issue_attachments( State(state): State, Extension(ctx): Extension, Path(issue_id): Path, ) -> Result, RouteError> { ensure_issue_access(state.pool(), ctx.user.id, issue_id) .await .map_err(|_| RouteError::AccessDenied)?; let azure = state.azure_blob(); let attachments = AttachmentRepository::find_by_issue_id(state.pool(), issue_id) .await? .into_iter() .map(|a| { let file_url = azure.and_then(|az| az.create_read_url(&a.blob_path).ok()); AttachmentWithUrl { attachment: a, file_url, } }) .collect(); Ok(Json(ListAttachmentsResponse { attachments })) } #[instrument(name = "attachments.list_comment", skip(state, ctx), fields(comment_id = %comment_id, user_id = %ctx.user.id))] async fn list_comment_attachments( State(state): State, Extension(ctx): Extension, Path(comment_id): Path, ) -> Result, RouteError> { ensure_comment_access(state.pool(), ctx.user.id, comment_id) .await .map_err(|_| RouteError::AccessDenied)?; let azure = state.azure_blob(); let attachments = AttachmentRepository::find_by_comment_id(state.pool(), comment_id) .await? .into_iter() .map(|a| { let file_url = azure.and_then(|az| az.create_read_url(&a.blob_path).ok()); AttachmentWithUrl { attachment: a, file_url, } }) .collect(); Ok(Json(ListAttachmentsResponse { attachments })) } #[instrument(name = "attachments.get_file", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))] async fn get_attachment_file( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result, RouteError> { let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id) .await? .ok_or(RouteError::NotFound)?; ensure_attachment_access(&state, ctx.user.id, &attachment).await?; let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; let url = azure.create_read_url(&attachment.blob_path)?; Ok(Json(AttachmentUrlResponse { url })) } #[instrument(name = "attachments.get_thumbnail", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))] async fn get_attachment_thumbnail( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result, RouteError> { let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id) .await? .ok_or(RouteError::NotFound)?; ensure_attachment_access(&state, ctx.user.id, &attachment).await?; let thumbnail_path = attachment .thumbnail_blob_path .ok_or(RouteError::NoThumbnail)?; let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; let url = azure.create_read_url(&thumbnail_path)?; Ok(Json(AttachmentUrlResponse { url })) } #[instrument(name = "attachments.delete", skip(state, ctx), fields(attachment_id = %id, user_id = %ctx.user.id))] async fn delete_attachment( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result { let attachment = AttachmentRepository::find_by_id_with_blob(state.pool(), id) .await? .ok_or(RouteError::NotFound)?; ensure_attachment_access(&state, ctx.user.id, &attachment).await?; let blob_id = attachment.blob_id; AttachmentRepository::delete(state.pool(), id).await?; let remaining = AttachmentRepository::count_by_blob_id(state.pool(), blob_id).await?; if remaining == 0 && let Some(blob) = BlobRepository::delete(state.pool(), blob_id).await? { let azure = state.azure_blob().ok_or(RouteError::NotConfigured)?; if let Err(e) = azure.delete_blob(&blob.blob_path).await { tracing::warn!(error = %e, blob_path = %blob.blob_path, "Failed to delete blob"); } if let Some(thumb_path) = blob.thumbnail_blob_path && let Err(e) = azure.delete_blob(&thumb_path).await { tracing::warn!(error = %e, blob_path = %thumb_path, "Failed to delete thumbnail"); } } Ok(StatusCode::NO_CONTENT) } async fn ensure_attachment_access( state: &AppState, user_id: Uuid, attachment: &AttachmentWithBlob, ) -> Result<(), RouteError> { if let Some(issue_id) = attachment.issue_id { ensure_issue_access(state.pool(), user_id, issue_id) .await .map_err(|_| RouteError::AccessDenied)?; } else if let Some(comment_id) = attachment.comment_id { ensure_comment_access(state.pool(), user_id, comment_id) .await .map_err(|_| RouteError::AccessDenied)?; } else if let Some(project_id) = AttachmentRepository::project_id(state.pool(), attachment.id).await? { ensure_project_access(state.pool(), user_id, project_id) .await .map_err(|_| RouteError::AccessDenied)?; } else { return Err(RouteError::AccessDenied); } Ok(()) } fn sanitize_filename(filename: &str) -> String { filename .chars() .map(|c| { if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' } }) .take(100) .collect() } ================================================ FILE: crates/remote/src/routes/billing.rs ================================================ use axum::{ Json, Router, body::Bytes, extract::{Extension, Path, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{get, post}, }; use uuid::Uuid; use super::{error::ErrorResponse, organization_members::ensure_admin_access}; use crate::{ AppState, auth::RequestContext, billing::{ BillingError, BillingStatus, BillingStatusResponse, CreateCheckoutRequest, CreatePortalRequest, }, db::organization_members, }; pub fn public_router() -> Router { Router::new().route("/billing/webhook", post(handle_webhook)) } pub fn protected_router() -> Router { Router::new() .route("/organizations/{org_id}/billing", get(get_billing_status)) .route( "/organizations/{org_id}/billing/portal", post(create_portal_session), ) .route( "/organizations/{org_id}/billing/checkout", post(create_checkout_session), ) } pub async fn get_billing_status( State(state): State, Extension(ctx): Extension, Path(org_id): Path, ) -> Result { organization_members::assert_membership(&state.pool, org_id, ctx.user.id) .await .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied"))?; match state.billing().provider() { Some(billing) => { let status = billing .get_billing_status(org_id) .await .map_err(billing_error)?; Ok(Json(status)) } None => Ok(Json(BillingStatusResponse { status: BillingStatus::Free, billing_enabled: false, seat_info: None, })), } } pub async fn create_portal_session( State(state): State, Extension(ctx): Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { ensure_admin_access(&state.pool, org_id, ctx.user.id) .await .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required"))?; let billing = state.billing().provider().ok_or_else(|| { ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "Billing not configured") })?; let session = billing .create_portal_session(org_id, &payload.return_url) .await .map_err(billing_error)?; Ok(Json(session)) } pub async fn create_checkout_session( State(state): State, Extension(ctx): Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { ensure_admin_access(&state.pool, org_id, ctx.user.id) .await .map_err(|_| ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required"))?; let billing = state.billing().provider().ok_or_else(|| { ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "Billing not configured") })?; let session = billing .create_checkout_session(org_id, &payload.success_url, &payload.cancel_url) .await .map_err(billing_error)?; Ok(Json(session)) } pub async fn handle_webhook( State(state): State, headers: HeaderMap, body: Bytes, ) -> Result { let billing = state.billing().provider().ok_or_else(|| { ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "Billing not configured") })?; let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .unwrap_or(""); billing .handle_webhook(&body, signature) .await .map_err(billing_error)?; Ok(StatusCode::OK) } fn billing_error(error: BillingError) -> ErrorResponse { match error { BillingError::NotConfigured => { ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "Billing not configured") } BillingError::SubscriptionRequired(msg) => { ErrorResponse::new(StatusCode::PAYMENT_REQUIRED, msg) } BillingError::SubscriptionInactive => { ErrorResponse::new(StatusCode::PAYMENT_REQUIRED, "Subscription is inactive") } BillingError::Stripe(msg) => { tracing::error!(?msg, "Stripe error"); ErrorResponse::new(StatusCode::BAD_GATEWAY, "Payment provider error") } BillingError::Database(e) => { tracing::error!(?e, "Database error in billing"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") } BillingError::OrganizationNotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } } } ================================================ FILE: crates/remote/src/routes/electric_proxy.rs ================================================ use std::collections::HashMap; use axum::{ Router, body::Body, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use futures::TryStreamExt; use secrecy::ExposeSecret; use serde::Deserialize; use tracing::error; use uuid::Uuid; use crate::{AppState, shape_definition::ShapeExport}; #[derive(Deserialize)] pub(crate) struct OrgShapeQuery { pub organization_id: Uuid, #[serde(flatten)] pub params: HashMap, } #[derive(Deserialize)] pub(crate) struct ShapeQuery { #[serde(flatten)] pub params: HashMap, } const ELECTRIC_PARAMS: &[&str] = &["offset", "handle", "live", "cursor", "columns"]; const ELECTRIC_STICKY_HEADER: &str = "x-vk-electric-sticky"; pub fn router() -> Router { let mut router = Router::new(); for route in crate::shape_routes::all_shape_routes() { router = router.merge(route.router); } router } /// Proxy a Shape request to Electric for a specific table. /// /// The table and where clause are set server-side (not from client params) /// to prevent unauthorized access to other tables or data. pub(crate) async fn proxy_table( state: &AppState, shape: &dyn ShapeExport, client_params: &HashMap, electric_params: &[String], session_id: Uuid, ) -> Result { // Build the Electric URL let mut origin_url = url::Url::parse(&state.config.electric_url) .map_err(|e| ProxyError::InvalidConfig(format!("invalid electric_url: {e}")))?; origin_url.set_path("/v1/shape"); // Set table server-side (security: client can't override) origin_url .query_pairs_mut() .append_pair("table", shape.table()); // Set WHERE clause with parameterized values origin_url .query_pairs_mut() .append_pair("where", shape.where_clause()); // Pass params for $1, $2, etc. placeholders for (i, param) in electric_params.iter().enumerate() { origin_url .query_pairs_mut() .append_pair(&format!("params[{}]", i + 1), param); } // Forward safe client params for (key, value) in client_params { if ELECTRIC_PARAMS.contains(&key.as_str()) { origin_url.query_pairs_mut().append_pair(key, value); } } if let Some(secret) = &state.config.electric_secret { origin_url .query_pairs_mut() .append_pair("secret", secret.expose_secret()); } let response = state .http_client .get(origin_url.as_str()) .header(ELECTRIC_STICKY_HEADER, session_id.to_string()) .send() .await .map_err(ProxyError::Connection)?; let status = response.status(); let mut headers = HeaderMap::new(); // Copy headers from Electric response, but remove problematic ones for (key, value) in response.headers() { // Skip headers that interfere with browser handling if key == header::CONTENT_ENCODING || key == header::CONTENT_LENGTH { continue; } headers.insert(key.clone(), value.clone()); } // Add Vary header for proper caching with auth headers.insert(header::VARY, HeaderValue::from_static("Authorization")); // Stream the response body directly without buffering let body_stream = response.bytes_stream().map_err(std::io::Error::other); let body = Body::from_stream(body_stream); Ok((status, headers, body).into_response()) } #[derive(Debug)] pub(crate) enum ProxyError { Connection(reqwest::Error), InvalidConfig(String), Authorization(String), } impl IntoResponse for ProxyError { fn into_response(self) -> Response { match self { ProxyError::Connection(err) => { error!(?err, "failed to connect to Electric service"); ( StatusCode::BAD_GATEWAY, "failed to connect to Electric service", ) .into_response() } ProxyError::InvalidConfig(msg) => { error!(%msg, "invalid Electric proxy configuration"); (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() } ProxyError::Authorization(msg) => { error!(%msg, "authorization failed for Electric proxy"); (StatusCode::FORBIDDEN, "forbidden").into_response() } } } } ================================================ FILE: crates/remote/src/routes/error.rs ================================================ use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; use serde_json::json; use crate::db::identity_errors::IdentityError; #[derive(Debug)] pub struct ErrorResponse { status: StatusCode, message: String, } impl ErrorResponse { pub fn new(status: StatusCode, message: impl Into) -> Self { Self { status, message: message.into(), } } } impl IntoResponse for ErrorResponse { fn into_response(self) -> Response { (self.status, Json(json!({ "error": self.message }))).into_response() } } pub(crate) fn db_error( error: impl std::error::Error + 'static, fallback_message: &str, ) -> ErrorResponse { let error: &(dyn std::error::Error + 'static) = &error; let mut current = Some(error); while let Some(err) = current { if let Some(sqlx_error) = err.downcast_ref::() { if let sqlx::Error::Database(db_err) = sqlx_error { if db_err.is_unique_violation() { return ErrorResponse::new(StatusCode::CONFLICT, "resource already exists"); } if db_err.is_foreign_key_violation() { return ErrorResponse::new(StatusCode::NOT_FOUND, "related resource not found"); } } break; } current = err.source(); } ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, fallback_message) } pub(crate) fn membership_error(error: IdentityError, forbidden_message: &str) -> ErrorResponse { match error { IdentityError::NotFound | IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message) } IdentityError::Database(_) => { ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") } other => { tracing::warn!(?other, "unexpected membership error"); ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message) } } } ================================================ FILE: crates/remote/src/routes/github_app.rs ================================================ use axum::{ Json, Router, body::Bytes, extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, routing::{delete, get, patch, post}, }; use chrono::{Duration, Utc}; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use uuid::Uuid; use super::error::ErrorResponse; use crate::{ AppState, auth::RequestContext, db::{ github_app::GitHubAppRepository2, identity_errors::IdentityError, organizations::OrganizationRepository, reviews::ReviewRepository, }, github_app::{PrReviewParams, PrReviewService, verify_webhook_signature}, }; // ========== Public Routes ========== pub fn public_router() -> Router { Router::new() .route("/github/webhook", post(handle_webhook)) .route("/github/app/callback", get(handle_callback)) } // ========== Protected Routes ========== pub fn protected_router() -> Router { Router::new() .route( "/organizations/{org_id}/github-app/install-url", get(get_install_url), ) .route("/organizations/{org_id}/github-app/status", get(get_status)) .route("/organizations/{org_id}/github-app", delete(uninstall)) .route( "/organizations/{org_id}/github-app/repositories", get(fetch_repositories), ) .route( "/organizations/{org_id}/github-app/repositories/review-enabled", patch(bulk_update_review_enabled), ) .route( "/organizations/{org_id}/github-app/repositories/{repo_id}/review-enabled", patch(update_repo_review_enabled), ) .route("/debug/pr-review/trigger", post(trigger_pr_review)) } // ========== Types ========== #[derive(Debug, Serialize)] pub struct InstallUrlResponse { pub install_url: String, } #[derive(Debug, Serialize)] pub struct GitHubAppStatusResponse { pub installed: bool, #[serde(skip_serializing_if = "Option::is_none")] pub installation: Option, pub repositories: Vec, } #[derive(Debug, Serialize)] pub struct InstallationDetails { pub id: String, pub github_installation_id: i64, pub github_account_login: String, pub github_account_type: String, pub repository_selection: String, pub suspended_at: Option, pub created_at: String, } #[derive(Debug, Serialize)] pub struct RepositoryDetails { pub id: String, pub github_repo_id: i64, pub repo_full_name: String, pub review_enabled: bool, } #[derive(Debug, Deserialize)] pub struct CallbackQuery { pub installation_id: Option, pub state: Option, } #[derive(Debug, Deserialize)] pub struct TriggerPrReviewRequest { /// GitHub PR URL, e.g., "https://github.com/owner/repo/pull/123" pub pr_url: String, } #[derive(Debug, Serialize)] pub struct TriggerPrReviewResponse { pub review_id: Uuid, } #[derive(Debug, Deserialize)] pub struct UpdateRepoReviewEnabledRequest { pub enabled: bool, } #[derive(Debug, Serialize)] pub struct BulkUpdateReviewEnabledResponse { pub updated_count: u64, } // ========== Protected Route Handlers ========== /// GET /v1/organizations/:org_id/github-app/install-url /// Returns URL to install the GitHub App for this organization pub async fn get_install_url( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { // Check GitHub App is configured let github_app = state.github_app().ok_or_else(|| { ErrorResponse::new(StatusCode::NOT_IMPLEMENTED, "GitHub App not configured") })?; // Check user is admin of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_admin(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; // Check not a personal org let is_personal = org_repo .is_personal(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; if is_personal { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "GitHub App cannot be installed on personal organizations", )); } // Generate state token (simple format: org_id:user_id:timestamp) // In production, you'd want to sign this with HMAC let expires_at = Utc::now() + Duration::minutes(10); let state_token = format!("{}:{}:{}", org_id, ctx.user.id, expires_at.timestamp()); // Store pending installation let gh_repo = GitHubAppRepository2::new(state.pool()); gh_repo .create_pending(org_id, ctx.user.id, &state_token, expires_at) .await .map_err(|e| { error!(?e, "Failed to create pending installation"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; // Build installation URL let install_url = format!( "https://github.com/apps/{}/installations/new?state={}", github_app.app_slug(), urlencoding::encode(&state_token) ); Ok(Json(InstallUrlResponse { install_url })) } /// GET /v1/organizations/:org_id/github-app/status /// Returns the GitHub App installation status for this organization pub async fn get_status( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { // Check user is member of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_membership(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied | IdentityError::NotFound => { ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo.get_by_organization(org_id).await.map_err(|e| { error!(?e, "Failed to get GitHub App installation"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; match installation { Some(inst) => { // Return cached repos from DB (fast) - use GET /repositories to fetch fresh data let repositories = gh_repo.get_repositories(inst.id).await.map_err(|e| { error!(?e, "Failed to get repositories"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; Ok(Json(GitHubAppStatusResponse { installed: true, installation: Some(InstallationDetails { id: inst.id.to_string(), github_installation_id: inst.github_installation_id, github_account_login: inst.github_account_login, github_account_type: inst.github_account_type, repository_selection: inst.repository_selection, suspended_at: inst.suspended_at.map(|t| t.to_rfc3339()), created_at: inst.created_at.to_rfc3339(), }), repositories: repositories .into_iter() .map(|r| RepositoryDetails { id: r.id.to_string(), github_repo_id: r.github_repo_id, repo_full_name: r.repo_full_name, review_enabled: r.review_enabled, }) .collect(), })) } None => Ok(Json(GitHubAppStatusResponse { installed: false, installation: None, repositories: vec![], })), } } /// DELETE /v1/organizations/:org_id/github-app /// Removes the local installation record (does not uninstall from GitHub) pub async fn uninstall( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { // Check user is admin of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_admin(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; let gh_repo = GitHubAppRepository2::new(state.pool()); gh_repo.delete_by_organization(org_id).await.map_err(|e| { error!(?e, "Failed to delete GitHub App installation"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; info!(org_id = %org_id, user_id = %ctx.user.id, "GitHub App installation removed"); Ok(StatusCode::NO_CONTENT) } /// PATCH /v1/organizations/:org_id/github-app/repositories/:repo_id/review-enabled /// Toggle whether a repository should trigger PR reviews pub async fn update_repo_review_enabled( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path((org_id, repo_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result { // Check user is admin of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_admin(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; // Get installation for this org let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo .get_by_organization(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "GitHub App not installed"))?; // Update the repository let updated = gh_repo .update_repository_review_enabled(repo_id, installation.id, payload.enabled) .await .map_err(|e| { error!(?e, "Failed to update repository review_enabled"); match e { crate::db::github_app::GitHubAppDbError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Repository not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), } })?; info!( org_id = %org_id, repo_id = %repo_id, review_enabled = payload.enabled, "Repository review_enabled updated" ); Ok(Json(RepositoryDetails { id: updated.id.to_string(), github_repo_id: updated.github_repo_id, repo_full_name: updated.repo_full_name, review_enabled: updated.review_enabled, })) } /// GET /v1/organizations/:org_id/github-app/repositories /// Fetches repositories from GitHub API, syncs to DB, and returns the list pub async fn fetch_repositories( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { // Check user is member of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_membership(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied | IdentityError::NotFound => { ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo .get_by_organization(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "GitHub App not installed"))?; // Fetch repos from GitHub API and sync to DB let github_app = state.github_app().ok_or_else(|| { ErrorResponse::new(StatusCode::NOT_IMPLEMENTED, "GitHub App not configured") })?; match github_app .list_installation_repos(installation.github_installation_id) .await { Ok(repos) => { let repo_data: Vec<(i64, String)> = repos.into_iter().map(|r| (r.id, r.full_name)).collect(); if let Err(e) = gh_repo.sync_repositories(installation.id, &repo_data).await { warn!(?e, "Failed to sync repositories from GitHub API"); } } Err(e) => { warn!(?e, "Failed to fetch repositories from GitHub API"); // Continue with cached data } } // Return the (now updated) list from DB let repositories = gh_repo .get_repositories(installation.id) .await .map_err(|e| { error!(?e, "Failed to get repositories"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; Ok(Json( repositories .into_iter() .map(|r| RepositoryDetails { id: r.id.to_string(), github_repo_id: r.github_repo_id, repo_full_name: r.repo_full_name, review_enabled: r.review_enabled, }) .collect::>(), )) } /// PATCH /v1/organizations/:org_id/github-app/repositories/review-enabled /// Bulk toggle review_enabled for all repositories pub async fn bulk_update_review_enabled( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { // Check user is admin of organization let org_repo = OrganizationRepository::new(state.pool()); org_repo .assert_admin(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo .get_by_organization(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "GitHub App not installed"))?; let updated_count = gh_repo .set_all_repositories_review_enabled(installation.id, payload.enabled) .await .map_err(|e| { error!(?e, "Failed to bulk update review_enabled"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; info!( org_id = %org_id, review_enabled = payload.enabled, updated_count, "Bulk updated repository review_enabled" ); Ok(Json(BulkUpdateReviewEnabledResponse { updated_count })) } // ========== Public Route Handlers ========== /// GET /v1/github/app/callback /// Handles redirect from GitHub after app installation pub async fn handle_callback( State(state): State, Query(query): Query, ) -> Response { let frontend_base = state .config .server_public_base_url .clone() .unwrap_or_else(|| "http://localhost:3000".to_string()); // Helper to redirect with error let redirect_error = |org_id: Option, error: &str| -> Response { let url = match org_id { Some(id) => format!( "{}/account/organizations/{}?github_app_error={}", frontend_base, id, urlencoding::encode(error) ), None => format!( "{}/account?github_app_error={}", frontend_base, urlencoding::encode(error) ), }; Redirect::temporary(&url).into_response() }; // Check GitHub App is configured let Some(github_app) = state.github_app() else { return redirect_error(None, "GitHub App not configured"); }; // Validate required params let Some(installation_id) = query.installation_id else { return redirect_error(None, "Missing installation_id"); }; let Some(state_token) = query.state else { return redirect_error(None, "Missing state parameter"); }; // Parse state token: org_id:user_id:timestamp let parts: Vec<&str> = state_token.split(':').collect(); if parts.len() != 3 { return redirect_error(None, "Invalid state token format"); } let Ok(org_id) = Uuid::parse_str(parts[0]) else { return redirect_error(None, "Invalid organization ID in state"); }; let Ok(user_id) = Uuid::parse_str(parts[1]) else { return redirect_error(Some(org_id), "Invalid user ID in state"); }; let Ok(timestamp) = parts[2].parse::() else { return redirect_error(Some(org_id), "Invalid timestamp in state"); }; // Check expiry if Utc::now().timestamp() > timestamp { return redirect_error(Some(org_id), "Installation link expired"); } // Verify pending installation exists let gh_repo = GitHubAppRepository2::new(state.pool()); let pending = match gh_repo.get_pending_by_state(&state_token).await { Ok(Some(p)) => p, Ok(None) => { return redirect_error(Some(org_id), "Installation not found or expired"); } Err(e) => { error!(?e, "Failed to get pending installation"); return redirect_error(Some(org_id), "Database error"); } }; // Fetch installation details from GitHub let installation_info = match github_app.get_installation(installation_id).await { Ok(info) => info, Err(e) => { error!(?e, "Failed to get installation from GitHub"); return redirect_error(Some(org_id), "Failed to verify installation with GitHub"); } }; // Create installation record if let Err(e) = gh_repo .create_installation( pending.organization_id, installation_id, &installation_info.account.login, &installation_info.account.account_type, &installation_info.repository_selection, user_id, ) .await { error!(?e, "Failed to create installation record"); return redirect_error(Some(org_id), "Failed to save installation"); } // Delete pending record if let Err(e) = gh_repo.delete_pending(&state_token).await { warn!(?e, "Failed to delete pending installation record"); } // Fetch and store repositories if selection is "selected" if installation_info.repository_selection == "selected" && let Ok(repos) = github_app.list_installation_repos(installation_id).await { let installation = gh_repo .get_by_github_id(installation_id) .await .ok() .flatten(); if let Some(inst) = installation { let repo_data: Vec<(i64, String)> = repos.into_iter().map(|r| (r.id, r.full_name)).collect(); if let Err(e) = gh_repo.sync_repositories(inst.id, &repo_data).await { warn!(?e, "Failed to sync repositories"); } } } info!( org_id = %org_id, installation_id = installation_id, account = %installation_info.account.login, "GitHub App installed successfully" ); // Redirect to organization page with success let url = format!( "{}/account/organizations/{}?github_app=installed", frontend_base, org_id ); Redirect::temporary(&url).into_response() } /// POST /v1/github/webhook /// Handles webhook events from GitHub pub async fn handle_webhook( State(state): State, headers: HeaderMap, body: Bytes, ) -> Response { // Check GitHub App is configured let Some(github_app) = state.github_app() else { warn!("Received webhook but GitHub App not configured"); return StatusCode::NOT_IMPLEMENTED.into_response(); }; // Verify signature let signature = headers .get("X-Hub-Signature-256") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if !verify_webhook_signature( github_app.webhook_secret().expose_secret().as_bytes(), signature, &body, ) { warn!("Invalid webhook signature"); return StatusCode::UNAUTHORIZED.into_response(); } // Get event type let event_type = headers .get("X-GitHub-Event") .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); info!(event_type, "Received GitHub webhook"); // Parse payload let payload: serde_json::Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(e) => { warn!(?e, "Failed to parse webhook payload"); return StatusCode::BAD_REQUEST.into_response(); } }; // Handle different event types match event_type { "installation" => handle_installation_event(&state, &payload).await, "installation_repositories" => handle_installation_repos_event(&state, &payload).await, "pull_request" => handle_pull_request_event(&state, github_app, &payload).await, "issue_comment" => handle_issue_comment_event(&state, github_app, &payload).await, _ => { info!(event_type, "Ignoring unhandled webhook event"); StatusCode::OK.into_response() } } } // ========== Webhook Event Handlers ========== async fn handle_installation_event(state: &AppState, payload: &serde_json::Value) -> Response { let action = payload["action"].as_str().unwrap_or(""); let installation_id = payload["installation"]["id"].as_i64().unwrap_or(0); info!(action, installation_id, "Processing installation event"); let gh_repo = GitHubAppRepository2::new(state.pool()); match action { "deleted" => { if let Err(e) = gh_repo.delete_by_github_id(installation_id).await { error!(?e, "Failed to delete installation"); } else { info!(installation_id, "Installation deleted"); } } "suspend" => { if let Err(e) = gh_repo.suspend(installation_id).await { error!(?e, "Failed to suspend installation"); } else { info!(installation_id, "Installation suspended"); } } "unsuspend" => { if let Err(e) = gh_repo.unsuspend(installation_id).await { error!(?e, "Failed to unsuspend installation"); } else { info!(installation_id, "Installation unsuspended"); } } "created" => { // Installation created via webhook (without going through our flow) // This shouldn't happen if orphan installations are rejected info!( installation_id, "Installation created event received (orphan)" ); } _ => { info!(action, "Ignoring installation action"); } } StatusCode::OK.into_response() } async fn handle_installation_repos_event( state: &AppState, payload: &serde_json::Value, ) -> Response { let action = payload["action"].as_str().unwrap_or(""); let installation_id = payload["installation"]["id"].as_i64().unwrap_or(0); info!( action, installation_id, "Processing installation_repositories event" ); let gh_repo = GitHubAppRepository2::new(state.pool()); // Get our installation record let installation = match gh_repo.get_by_github_id(installation_id).await { Ok(Some(inst)) => inst, Ok(None) => { info!(installation_id, "Installation not found, ignoring"); return StatusCode::OK.into_response(); } Err(e) => { error!(?e, "Failed to get installation"); return StatusCode::OK.into_response(); } }; match action { "added" => { let repos: Vec<(i64, String)> = payload["repositories_added"] .as_array() .unwrap_or(&vec![]) .iter() .filter_map(|r| { let id = r["id"].as_i64()?; let name = r["full_name"].as_str()?; Some((id, name.to_string())) }) .collect(); if let Err(e) = gh_repo.add_repositories(installation.id, &repos).await { error!(?e, "Failed to add repositories"); } else { info!(installation_id, count = repos.len(), "Repositories added"); } } "removed" => { let repo_ids: Vec = payload["repositories_removed"] .as_array() .unwrap_or(&vec![]) .iter() .filter_map(|r| r["id"].as_i64()) .collect(); if let Err(e) = gh_repo .remove_repositories(installation.id, &repo_ids) .await { error!(?e, "Failed to remove repositories"); } else { info!( installation_id, count = repo_ids.len(), "Repositories removed" ); } } _ => { info!(action, "Ignoring repositories action"); } } // Update repository selection if changed let new_selection = payload["repository_selection"].as_str().unwrap_or(""); if !new_selection.is_empty() && new_selection != installation.repository_selection && let Err(e) = gh_repo .update_repository_selection(installation_id, new_selection) .await { error!(?e, "Failed to update repository selection"); } StatusCode::OK.into_response() } // ========== Shared PR Review Trigger Logic ========== /// Parameters for triggering a PR review from webhook events struct TriggerReviewContext<'a> { installation_id: i64, github_repo_id: i64, repo_owner: &'a str, repo_name: &'a str, pr_number: u64, /// PR metadata - if None, will be fetched from GitHub API pr_metadata: Option, } struct PrMetadata { title: String, body: String, head_sha: String, base_ref: String, } /// Shared logic to validate and trigger a PR review. /// Returns Ok(()) if review was triggered, Err with reason if skipped. async fn try_trigger_pr_review( state: &AppState, github_app: &crate::github_app::GitHubAppService, ctx: TriggerReviewContext<'_>, check_pending: bool, ) -> Result<(), &'static str> { if state.config.review_disabled { return Err("Review feature is disabled"); } // Check if we have this installation let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo .get_by_github_id(ctx.installation_id) .await .map_err(|_| "Failed to get installation")? .ok_or("Installation not found")?; // Check if installation is suspended if installation.suspended_at.is_some() { return Err("Installation is suspended"); } // Check if repository has reviews enabled let is_review_enabled = gh_repo .is_repository_review_enabled(installation.id, ctx.github_repo_id) .await .unwrap_or(true); if !is_review_enabled { return Err("Repository has reviews disabled"); } // Optionally check for pending review if check_pending { let review_repo = ReviewRepository::new(state.pool()); if review_repo .has_pending_review_for_pr(ctx.repo_owner, ctx.repo_name, ctx.pr_number as i32) .await .unwrap_or(false) { return Err("Review already pending"); } } // Check if R2 and review worker are configured let r2 = state.r2().ok_or("R2 not configured")?; let worker_base_url = state .config .review_worker_base_url .as_ref() .ok_or("Review worker not configured")?; // Get PR metadata (from payload or fetch from API) let (pr_title, pr_body, head_sha, base_ref) = match ctx.pr_metadata { Some(meta) => (meta.title, meta.body, meta.head_sha, meta.base_ref), None => { let pr_details = github_app .get_pr_details( ctx.installation_id, ctx.repo_owner, ctx.repo_name, ctx.pr_number, ) .await .map_err(|_| "Failed to fetch PR details")?; ( pr_details.title, pr_details.body.unwrap_or_default(), pr_details.head.sha, pr_details.base.ref_name, ) } }; // Spawn async task to process PR review let github_app_clone = github_app.clone(); let r2_clone = r2.clone(); let http_client = state.http_client.clone(); let worker_url = worker_base_url.clone(); let server_url = state.server_public_base_url.clone(); let pool = state.pool.clone(); let installation_id = ctx.installation_id; let pr_number = ctx.pr_number; let repo_owner = ctx.repo_owner.to_string(); let repo_name = ctx.repo_name.to_string(); tokio::spawn(async move { let service = PrReviewService::new( github_app_clone, r2_clone, http_client, worker_url, server_url, ); let params = PrReviewParams { installation_id, owner: repo_owner.clone(), repo: repo_name.clone(), pr_number, pr_title, pr_body, head_sha, base_ref, }; if let Err(e) = service.process_pr_review(&pool, params).await { error!( ?e, installation_id, pr_number, repo_owner, repo_name, "Failed to start PR review" ); } }); Ok(()) } async fn handle_pull_request_event( state: &AppState, github_app: &crate::github_app::GitHubAppService, payload: &serde_json::Value, ) -> Response { let action = payload["action"].as_str().unwrap_or(""); if action != "opened" { return StatusCode::OK.into_response(); } let ctx = TriggerReviewContext { installation_id: payload["installation"]["id"].as_i64().unwrap_or(0), github_repo_id: payload["repository"]["id"].as_i64().unwrap_or(0), repo_owner: payload["repository"]["owner"]["login"] .as_str() .unwrap_or(""), repo_name: payload["repository"]["name"].as_str().unwrap_or(""), pr_number: payload["pull_request"]["number"].as_u64().unwrap_or(0), pr_metadata: Some(PrMetadata { title: payload["pull_request"]["title"] .as_str() .unwrap_or("Untitled PR") .to_string(), body: payload["pull_request"]["body"] .as_str() .unwrap_or("") .to_string(), head_sha: payload["pull_request"]["head"]["sha"] .as_str() .unwrap_or("") .to_string(), base_ref: payload["pull_request"]["base"]["ref"] .as_str() .unwrap_or("main") .to_string(), }), }; info!( installation_id = ctx.installation_id, pr_number = ctx.pr_number, repo_owner = ctx.repo_owner, repo_name = ctx.repo_name, "Processing pull_request.opened event" ); if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, false).await { info!(reason, "Skipping PR review"); } StatusCode::OK.into_response() } async fn handle_issue_comment_event( state: &AppState, github_app: &crate::github_app::GitHubAppService, payload: &serde_json::Value, ) -> Response { let action = payload["action"].as_str().unwrap_or(""); // Only handle new comments if action != "created" { return StatusCode::OK.into_response(); } // Check if comment is on a PR (issues don't have pull_request field) if payload["issue"]["pull_request"].is_null() { return StatusCode::OK.into_response(); } // Check for exact "!reviewfast" trigger let comment_body = payload["comment"]["body"].as_str().unwrap_or("").trim(); if comment_body != "!reviewfast" { return StatusCode::OK.into_response(); } // Ignore bot comments to prevent loops let user_type = payload["comment"]["user"]["type"].as_str().unwrap_or(""); if user_type == "Bot" { info!("Ignoring !reviewfast from bot user"); return StatusCode::OK.into_response(); } let ctx = TriggerReviewContext { installation_id: payload["installation"]["id"].as_i64().unwrap_or(0), github_repo_id: payload["repository"]["id"].as_i64().unwrap_or(0), repo_owner: payload["repository"]["owner"]["login"] .as_str() .unwrap_or(""), repo_name: payload["repository"]["name"].as_str().unwrap_or(""), pr_number: payload["issue"]["number"].as_u64().unwrap_or(0), pr_metadata: None, // Will fetch from GitHub API }; info!( installation_id = ctx.installation_id, pr_number = ctx.pr_number, repo_owner = ctx.repo_owner, repo_name = ctx.repo_name, "Processing !reviewfast comment" ); // Pass check_pending=true to skip if review already in progress if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, true).await { info!(reason, "Skipping PR review from !reviewfast"); } StatusCode::OK.into_response() } // ========== Debug Endpoint ========== /// Parse a GitHub PR URL into (owner, repo, pr_number) fn parse_pr_url(url: &str) -> Option<(String, String, u64)> { // Parse URLs like: https://github.com/owner/repo/pull/123 let url = url.trim_end_matches('/'); let parts: Vec<&str> = url.split('/').collect(); // Find "github.com" and get owner/repo/pull/number let github_idx = parts.iter().position(|&p| p == "github.com")?; if parts.len() < github_idx + 5 { return None; } let owner = parts[github_idx + 1].to_string(); let repo = parts[github_idx + 2].to_string(); if parts[github_idx + 3] != "pull" { return None; } let pr_number: u64 = parts[github_idx + 4].parse().ok()?; Some((owner, repo, pr_number)) } /// POST /v1/debug/pr-review/trigger /// Manually trigger a PR review for debugging purposes pub async fn trigger_pr_review( State(state): State, Json(payload): Json, ) -> Result, ErrorResponse> { if state.config.review_disabled { return Err(ErrorResponse::new( StatusCode::SERVICE_UNAVAILABLE, "Review feature is disabled", )); } // 1. Parse PR URL let (owner, repo, pr_number) = parse_pr_url(&payload.pr_url) .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, "Invalid PR URL format"))?; // 2. Validate services are configured let github_app = state.github_app().ok_or_else(|| { ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "GitHub App not configured") })?; let r2 = state .r2() .ok_or_else(|| ErrorResponse::new(StatusCode::SERVICE_UNAVAILABLE, "R2 not configured"))?; let worker_base_url = state .config .review_worker_base_url .as_ref() .ok_or_else(|| { ErrorResponse::new( StatusCode::SERVICE_UNAVAILABLE, "Review worker not configured", ) })?; // 3. Look up installation by owner let gh_repo = GitHubAppRepository2::new(state.pool()); let installation = gh_repo .get_by_account_login(&owner) .await .map_err(|e| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or_else(|| { ErrorResponse::new( StatusCode::NOT_FOUND, format!("No installation found for {}", owner), ) })?; // 4. Fetch PR details from GitHub API let pr_details = github_app .get_pr_details( installation.github_installation_id, &owner, &repo, pr_number, ) .await .map_err(|e| ErrorResponse::new(StatusCode::BAD_GATEWAY, e.to_string()))?; // 5. Create service and process review let service = PrReviewService::new( github_app.clone(), r2.clone(), state.http_client.clone(), worker_base_url.clone(), state.server_public_base_url.clone(), ); let params = PrReviewParams { installation_id: installation.github_installation_id, owner, repo, pr_number, pr_title: pr_details.title, pr_body: pr_details.body.unwrap_or_default(), head_sha: pr_details.head.sha, base_ref: pr_details.base.ref_name, }; let review_id = service .process_pr_review(state.pool(), params) .await .map_err(|e| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; info!( review_id = %review_id, pr_url = %payload.pr_url, "Manual PR review triggered" ); Ok(Json(TriggerPrReviewResponse { review_id })) } ================================================ FILE: crates/remote/src/routes/hosts.rs ================================================ use api_types::{ListRelayHostsResponse, RelaySession}; use axum::{ Json, Router, extract::{Extension, Path, State}, http::StatusCode, routing::{get, post}, }; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; use super::error::ErrorResponse; use crate::{ AppState, auth::RequestContext, db::{hosts::HostRepository, identity_errors::IdentityError}, }; const RELAY_SESSION_TTL_SECS: i64 = 120; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct CreateRelaySessionResponse { pub session: RelaySession, } pub fn router() -> Router { Router::new() .route("/hosts", get(list_hosts)) .route("/hosts/{host_id}/sessions", post(create_relay_session)) } async fn list_hosts( State(state): State, Extension(ctx): Extension, ) -> Result, ErrorResponse> { let repo = HostRepository::new(state.pool()); let hosts = repo .list_accessible_hosts(ctx.user.id) .await .map_err(|error| { tracing::warn!(?error, "failed to list relay hosts"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list hosts") })?; Ok(Json(ListRelayHostsResponse { hosts })) } async fn create_relay_session( State(state): State, Extension(ctx): Extension, Path(host_id): Path, ) -> Result, ErrorResponse> { let repo = HostRepository::new(state.pool()); repo.assert_host_access(host_id, ctx.user.id) .await .map_err(|error| match error { IdentityError::Database(_) => { ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") } IdentityError::PermissionDenied | IdentityError::NotFound => { ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied") } _ => ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied"), })?; let expires_at = Utc::now() + Duration::seconds(RELAY_SESSION_TTL_SECS); let session = repo .create_session(host_id, ctx.user.id, expires_at) .await .map_err(|_| { ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session", ) })?; if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "relay_host_session_created", serde_json::json!({ "host_id": host_id, "relay_session_id": session.id, }), ); } Ok(Json(CreateRelaySessionResponse { session })) } ================================================ FILE: crates/remote/src/routes/identity.rs ================================================ use axum::{Extension, Json, Router, routing::get}; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use crate::{AppState, auth::RequestContext}; #[derive(Debug, Serialize, Deserialize)] pub struct IdentityResponse { pub user_id: Uuid, pub username: Option, pub email: String, } pub fn router() -> Router { Router::new().route("/identity", get(get_identity)) } #[instrument(name = "identity.get_identity", skip(ctx), fields(user_id = %ctx.user.id))] pub async fn get_identity(Extension(ctx): Extension) -> Json { let user = ctx.user; Json(IdentityResponse { user_id: user.id, username: user.username, email: user.email, }) } ================================================ FILE: crates/remote/src/routes/issue_assignees.rs ================================================ use api_types::{ CreateIssueAssigneeRequest, DeleteResponse, IssueAssignee, ListIssueAssigneesQuery, ListIssueAssigneesResponse, MutationResponse, NotificationPayload, NotificationType, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::{issue_assignees::IssueAssigneeRepository, issues::IssueRepository}, mutation_definition::{MutationBuilder, NoUpdate}, notifications::notify_user, }; /// Mutation definition for IssueAssignee - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issue_assignees") .list(list_issue_assignees) .get(get_issue_assignee) .create(create_issue_assignee) .delete(delete_issue_assignee) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "issue_assignees.list_issue_assignees", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_issue_assignees( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_assignees = IssueAssigneeRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue assignees"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue assignees", ) })?; Ok(Json(ListIssueAssigneesResponse { issue_assignees })) } #[instrument( name = "issue_assignees.get_issue_assignee", skip(state, ctx), fields(issue_assignee_id = %issue_assignee_id, user_id = %ctx.user.id) )] async fn get_issue_assignee( State(state): State, Extension(ctx): Extension, Path(issue_assignee_id): Path, ) -> Result, ErrorResponse> { let assignee = IssueAssigneeRepository::find_by_id(state.pool(), issue_assignee_id) .await .map_err(|error| { tracing::error!(?error, %issue_assignee_id, "failed to load issue assignee"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue assignee", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue assignee not found"))?; ensure_issue_access(state.pool(), ctx.user.id, assignee.issue_id).await?; Ok(Json(assignee)) } #[instrument( name = "issue_assignees.create_issue_assignee", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, user_id = %ctx.user.id) )] async fn create_issue_assignee( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { let organization_id = ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; let response = IssueAssigneeRepository::create( state.pool(), payload.id, payload.issue_id, payload.user_id, ) .await .map_err(|error| { tracing::error!(?error, "failed to create issue assignee"); db_error(error, "failed to create issue assignee") })?; if payload.user_id != ctx.user.id && let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), payload.issue_id).await { notify_user( state.pool(), organization_id, ctx.user.id, payload.user_id, &issue, NotificationType::IssueAssigneeChanged, NotificationPayload { assignee_user_id: Some(payload.user_id), ..Default::default() }, ) .await; } Ok(Json(response)) } #[instrument( name = "issue_assignees.delete_issue_assignee", skip(state, ctx), fields(issue_assignee_id = %issue_assignee_id, user_id = %ctx.user.id) )] async fn delete_issue_assignee( State(state): State, Extension(ctx): Extension, Path(issue_assignee_id): Path, ) -> Result, ErrorResponse> { let assignee = IssueAssigneeRepository::find_by_id(state.pool(), issue_assignee_id) .await .map_err(|error| { tracing::error!(?error, %issue_assignee_id, "failed to load issue assignee"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue assignee", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue assignee not found"))?; let organization_id = ensure_issue_access(state.pool(), ctx.user.id, assignee.issue_id).await?; let response = IssueAssigneeRepository::delete(state.pool(), issue_assignee_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue assignee"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; if assignee.user_id != ctx.user.id && let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), assignee.issue_id).await { notify_user( state.pool(), organization_id, ctx.user.id, assignee.user_id, &issue, NotificationType::IssueUnassigned, NotificationPayload { assignee_user_id: Some(assignee.user_id), ..Default::default() }, ) .await; } Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issue_comment_reactions.rs ================================================ use api_types::{ CreateIssueCommentReactionRequest, DeleteResponse, IssueComment, IssueCommentReaction, ListIssueCommentReactionsQuery, ListIssueCommentReactionsResponse, MutationResponse, NotificationPayload, NotificationType, UpdateIssueCommentReactionRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::{ issue_comment_reactions::IssueCommentReactionRepository, issue_comments::IssueCommentRepository, issues::IssueRepository, organization_members::is_member, }, mutation_definition::MutationBuilder, notifications::send_issue_notifications, }; /// Mutation definition for IssueCommentReaction - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder< IssueCommentReaction, CreateIssueCommentReactionRequest, UpdateIssueCommentReactionRequest, > { MutationBuilder::new("issue_comment_reactions") .list(list_issue_comment_reactions) .get(get_issue_comment_reaction) .create(create_issue_comment_reaction) .update(update_issue_comment_reaction) .delete(delete_issue_comment_reaction) } pub fn router() -> axum::Router { mutation().router() } async fn notify_comment_author_about_reaction( state: &AppState, organization_id: Uuid, actor_user_id: Uuid, comment: &IssueComment, emoji: &str, ) { let Some(comment_author_id) = comment.author_id else { return; }; if comment_author_id == actor_user_id || !is_member(state.pool(), organization_id, comment_author_id) .await .unwrap_or(false) { return; } let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), comment.issue_id).await else { return; }; send_issue_notifications( state.pool(), organization_id, actor_user_id, &[comment_author_id], &issue, NotificationType::IssueCommentReaction, NotificationPayload { comment_preview: Some(comment.message.chars().take(100).collect::()), emoji: Some(emoji.to_owned()), ..Default::default() }, Some(comment.id), Some(issue.id), ) .await; } #[instrument( name = "issue_comment_reactions.list_issue_comment_reactions", skip(state, ctx), fields(comment_id = %query.comment_id, user_id = %ctx.user.id) )] async fn list_issue_comment_reactions( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { let comment = IssueCommentRepository::find_by_id(state.pool(), query.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %query.comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let issue_comment_reactions = IssueCommentReactionRepository::list_by_comment(state.pool(), query.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %query.comment_id, "failed to list reactions"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list reactions", ) })?; Ok(Json(ListIssueCommentReactionsResponse { issue_comment_reactions, })) } #[instrument( name = "issue_comment_reactions.get_issue_comment_reaction", skip(state, ctx), fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id) )] async fn get_issue_comment_reaction( State(state): State, Extension(ctx): Extension, Path(issue_comment_reaction_id): Path, ) -> Result, ErrorResponse> { let reaction = IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_reaction_id, "failed to load reaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load reaction") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "reaction not found"))?; let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %reaction.comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; Ok(Json(reaction)) } #[instrument( name = "issue_comment_reactions.create_issue_comment_reaction", skip(state, ctx, payload), fields(comment_id = %payload.comment_id, user_id = %ctx.user.id) )] async fn create_issue_comment_reaction( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { let comment = IssueCommentRepository::find_by_id(state.pool(), payload.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %payload.comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let response = IssueCommentReactionRepository::create( state.pool(), payload.id, payload.comment_id, ctx.user.id, payload.emoji, ) .await .map_err(|error| { tracing::error!(?error, "failed to create reaction"); db_error(error, "failed to create reaction") })?; notify_comment_author_about_reaction( &state, organization_id, ctx.user.id, &comment, &response.data.emoji, ) .await; Ok(Json(response)) } #[instrument( name = "issue_comment_reactions.update_issue_comment_reaction", skip(state, ctx, payload), fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id) )] async fn update_issue_comment_reaction( State(state): State, Extension(ctx): Extension, Path(issue_comment_reaction_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let reaction = IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_reaction_id, "failed to load reaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load reaction") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "reaction not found"))?; if reaction.user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::FORBIDDEN, "you are not the author of this reaction", )); } let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %reaction.comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let response = IssueCommentReactionRepository::update( state.pool(), issue_comment_reaction_id, payload.emoji, ) .await .map_err(|error| { tracing::error!(?error, "failed to update reaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; notify_comment_author_about_reaction( &state, organization_id, ctx.user.id, &comment, &response.data.emoji, ) .await; Ok(Json(response)) } #[instrument( name = "issue_comment_reactions.delete_issue_comment_reaction", skip(state, ctx), fields(issue_comment_reaction_id = %issue_comment_reaction_id, user_id = %ctx.user.id) )] async fn delete_issue_comment_reaction( State(state): State, Extension(ctx): Extension, Path(issue_comment_reaction_id): Path, ) -> Result, ErrorResponse> { let reaction = IssueCommentReactionRepository::find_by_id(state.pool(), issue_comment_reaction_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_reaction_id, "failed to load reaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load reaction") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "reaction not found"))?; if reaction.user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::FORBIDDEN, "you are not the author of this reaction", )); } let comment = IssueCommentRepository::find_by_id(state.pool(), reaction.comment_id) .await .map_err(|error| { tracing::error!(?error, comment_id = %reaction.comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let response = IssueCommentReactionRepository::delete(state.pool(), issue_comment_reaction_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete reaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issue_comments.rs ================================================ use api_types::{ CreateIssueCommentRequest, DeleteResponse, IssueComment, ListIssueCommentsQuery, ListIssueCommentsResponse, MemberRole, MutationResponse, NotificationPayload, NotificationType, UpdateIssueCommentRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::{ issue_comments::IssueCommentRepository, issues::IssueRepository, organization_members::check_user_role, }, mutation_definition::MutationBuilder, notifications::notify_issue_subscribers, }; /// Mutation definition for IssueComment - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issue_comments") .list(list_issue_comments) .get(get_issue_comment) .create(create_issue_comment) .update(update_issue_comment) .delete(delete_issue_comment) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "issue_comments.list_issue_comments", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_issue_comments( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_comments = IssueCommentRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue comments"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue comments", ) })?; Ok(Json(ListIssueCommentsResponse { issue_comments })) } #[instrument( name = "issue_comments.get_issue_comment", skip(state, ctx), fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id) )] async fn get_issue_comment( State(state): State, Extension(ctx): Extension, Path(issue_comment_id): Path, ) -> Result, ErrorResponse> { let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_id, "failed to load issue comment"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue comment", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue comment not found"))?; ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; Ok(Json(comment)) } #[instrument( name = "issue_comments.create_issue_comment", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, user_id = %ctx.user.id) )] async fn create_issue_comment( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { let organization_id = ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; let is_reply = payload.parent_id.is_some(); let response = IssueCommentRepository::create( state.pool(), payload.id, payload.issue_id, ctx.user.id, payload.parent_id, payload.message, ) .await .map_err(|error| { tracing::error!(?error, "failed to create issue comment"); db_error(error, "failed to create issue comment") })?; if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "issue_comment_created", serde_json::json!({ "comment_id": response.data.id, "issue_id": response.data.issue_id, "organization_id": organization_id, "is_reply": is_reply, }), ); } if let Ok(Some(issue)) = IssueRepository::find_by_id(state.pool(), response.data.issue_id).await { let comment_preview = response.data.message.chars().take(100).collect::(); notify_issue_subscribers( state.pool(), organization_id, ctx.user.id, &issue, NotificationType::IssueCommentAdded, NotificationPayload { comment_preview: Some(comment_preview), ..Default::default() }, Some(response.data.id), ) .await; } Ok(Json(response)) } #[instrument( name = "issue_comments.update_issue_comment", skip(state, ctx, payload), fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id) )] async fn update_issue_comment( State(state): State, Extension(ctx): Extension, Path(issue_comment_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_id, "failed to load issue comment"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue comment", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue comment not found"))?; let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let is_author = comment .author_id .map(|id| id == ctx.user.id) .unwrap_or(false); let is_admin = check_user_role(state.pool(), organization_id, ctx.user.id) .await .map_err(|error| { tracing::error!(?error, "failed to check user role"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? .map(|role| role == MemberRole::Admin) .unwrap_or(false); if !is_author && !is_admin { return Err(ErrorResponse::new( StatusCode::FORBIDDEN, "you do not have permission to edit this comment", )); } let response = IssueCommentRepository::update(state.pool(), issue_comment_id, payload.message) .await .map_err(|error| { tracing::error!(?error, "failed to update issue comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } #[instrument( name = "issue_comments.delete_issue_comment", skip(state, ctx), fields(issue_comment_id = %issue_comment_id, user_id = %ctx.user.id) )] async fn delete_issue_comment( State(state): State, Extension(ctx): Extension, Path(issue_comment_id): Path, ) -> Result, ErrorResponse> { let comment = IssueCommentRepository::find_by_id(state.pool(), issue_comment_id) .await .map_err(|error| { tracing::error!(?error, %issue_comment_id, "failed to load issue comment"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue comment", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue comment not found"))?; let organization_id = ensure_issue_access(state.pool(), ctx.user.id, comment.issue_id).await?; let is_author = comment .author_id .map(|id| id == ctx.user.id) .unwrap_or(false); let is_admin = check_user_role(state.pool(), organization_id, ctx.user.id) .await .map_err(|error| { tracing::error!(?error, "failed to check user role"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? .map(|role| role == MemberRole::Admin) .unwrap_or(false); if !is_author && !is_admin { return Err(ErrorResponse::new( StatusCode::FORBIDDEN, "you do not have permission to delete this comment", )); } let response = IssueCommentRepository::delete(state.pool(), issue_comment_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issue_followers.rs ================================================ use api_types::{ CreateIssueFollowerRequest, DeleteResponse, IssueFollower, ListIssueFollowersQuery, ListIssueFollowersResponse, MutationResponse, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::issue_followers::IssueFollowerRepository, mutation_definition::{MutationBuilder, NoUpdate}, }; /// Mutation definition for IssueFollower - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issue_followers") .list(list_issue_followers) .get(get_issue_follower) .create(create_issue_follower) .delete(delete_issue_follower) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "issue_followers.list_issue_followers", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_issue_followers( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_followers = IssueFollowerRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue followers"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue followers", ) })?; Ok(Json(ListIssueFollowersResponse { issue_followers })) } #[instrument( name = "issue_followers.get_issue_follower", skip(state, ctx), fields(issue_follower_id = %issue_follower_id, user_id = %ctx.user.id) )] async fn get_issue_follower( State(state): State, Extension(ctx): Extension, Path(issue_follower_id): Path, ) -> Result, ErrorResponse> { let follower = IssueFollowerRepository::find_by_id(state.pool(), issue_follower_id) .await .map_err(|error| { tracing::error!(?error, %issue_follower_id, "failed to load issue follower"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue follower", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue follower not found"))?; ensure_issue_access(state.pool(), ctx.user.id, follower.issue_id).await?; Ok(Json(follower)) } #[instrument( name = "issue_followers.create_issue_follower", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, user_id = %ctx.user.id) )] async fn create_issue_follower( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; let response = IssueFollowerRepository::create( state.pool(), payload.id, payload.issue_id, payload.user_id, ) .await .map_err(|error| { tracing::error!(?error, "failed to create issue follower"); db_error(error, "failed to create issue follower") })?; Ok(Json(response)) } #[instrument( name = "issue_followers.delete_issue_follower", skip(state, ctx), fields(issue_follower_id = %issue_follower_id, user_id = %ctx.user.id) )] async fn delete_issue_follower( State(state): State, Extension(ctx): Extension, Path(issue_follower_id): Path, ) -> Result, ErrorResponse> { let follower = IssueFollowerRepository::find_by_id(state.pool(), issue_follower_id) .await .map_err(|error| { tracing::error!(?error, %issue_follower_id, "failed to load issue follower"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue follower", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue follower not found"))?; ensure_issue_access(state.pool(), ctx.user.id, follower.issue_id).await?; let response = IssueFollowerRepository::delete(state.pool(), issue_follower_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue follower"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issue_relationships.rs ================================================ use api_types::{ CreateIssueRelationshipRequest, DeleteResponse, IssueRelationship, ListIssueRelationshipsQuery, ListIssueRelationshipsResponse, MutationResponse, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::issue_relationships::IssueRelationshipRepository, mutation_definition::{MutationBuilder, NoUpdate}, }; /// Mutation definition for IssueRelationship - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issue_relationships") .list(list_issue_relationships) .get(get_issue_relationship) .create(create_issue_relationship) .delete(delete_issue_relationship) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "issue_relationships.list_issue_relationships", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_issue_relationships( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_relationships = IssueRelationshipRepository::list_by_issue( state.pool(), query.issue_id, ) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue relationships"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue relationships", ) })?; Ok(Json(ListIssueRelationshipsResponse { issue_relationships, })) } #[instrument( name = "issue_relationships.get_issue_relationship", skip(state, ctx), fields(issue_relationship_id = %issue_relationship_id, user_id = %ctx.user.id) )] async fn get_issue_relationship( State(state): State, Extension(ctx): Extension, Path(issue_relationship_id): Path, ) -> Result, ErrorResponse> { let relationship = IssueRelationshipRepository::find_by_id(state.pool(), issue_relationship_id) .await .map_err(|error| { tracing::error!(?error, %issue_relationship_id, "failed to load issue relationship"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue relationship", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue relationship not found"))?; ensure_issue_access(state.pool(), ctx.user.id, relationship.issue_id).await?; Ok(Json(relationship)) } #[instrument( name = "issue_relationships.create_issue_relationship", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, user_id = %ctx.user.id) )] async fn create_issue_relationship( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; let response = IssueRelationshipRepository::create( state.pool(), payload.id, payload.issue_id, payload.related_issue_id, payload.relationship_type, ) .await .map_err(|error| { tracing::error!(?error, "failed to create issue relationship"); db_error(error, "failed to create issue relationship") })?; Ok(Json(response)) } #[instrument( name = "issue_relationships.delete_issue_relationship", skip(state, ctx), fields(issue_relationship_id = %issue_relationship_id, user_id = %ctx.user.id) )] async fn delete_issue_relationship( State(state): State, Extension(ctx): Extension, Path(issue_relationship_id): Path, ) -> Result, ErrorResponse> { let relationship = IssueRelationshipRepository::find_by_id(state.pool(), issue_relationship_id) .await .map_err(|error| { tracing::error!(?error, %issue_relationship_id, "failed to load issue relationship"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue relationship", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue relationship not found"))?; ensure_issue_access(state.pool(), ctx.user.id, relationship.issue_id).await?; let response = IssueRelationshipRepository::delete(state.pool(), issue_relationship_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue relationship"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issue_tags.rs ================================================ use api_types::{ CreateIssueTagRequest, DeleteResponse, IssueTag, ListIssueTagsQuery, ListIssueTagsResponse, MutationResponse, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::issue_tags::IssueTagRepository, mutation_definition::{MutationBuilder, NoUpdate}, }; /// Mutation definition for IssueTag - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issue_tags") .list(list_issue_tags) .get(get_issue_tag) .create(create_issue_tag) .delete(delete_issue_tag) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "issue_tags.list_issue_tags", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_issue_tags( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_tags = IssueTagRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue tags"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue tags", ) })?; Ok(Json(ListIssueTagsResponse { issue_tags })) } #[instrument( name = "issue_tags.get_issue_tag", skip(state, ctx), fields(issue_tag_id = %issue_tag_id, user_id = %ctx.user.id) )] async fn get_issue_tag( State(state): State, Extension(ctx): Extension, Path(issue_tag_id): Path, ) -> Result, ErrorResponse> { let issue_tag = IssueTagRepository::find_by_id(state.pool(), issue_tag_id) .await .map_err(|error| { tracing::error!(?error, %issue_tag_id, "failed to load issue tag"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue tag", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue tag not found"))?; ensure_issue_access(state.pool(), ctx.user.id, issue_tag.issue_id).await?; Ok(Json(issue_tag)) } #[instrument( name = "issue_tags.create_issue_tag", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, user_id = %ctx.user.id) )] async fn create_issue_tag( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; let response = IssueTagRepository::create(state.pool(), payload.id, payload.issue_id, payload.tag_id) .await .map_err(|error| { tracing::error!(?error, "failed to create issue tag"); db_error(error, "failed to create issue tag") })?; Ok(Json(response)) } #[instrument( name = "issue_tags.delete_issue_tag", skip(state, ctx), fields(issue_tag_id = %issue_tag_id, user_id = %ctx.user.id) )] async fn delete_issue_tag( State(state): State, Extension(ctx): Extension, Path(issue_tag_id): Path, ) -> Result, ErrorResponse> { let issue_tag = IssueTagRepository::find_by_id(state.pool(), issue_tag_id) .await .map_err(|error| { tracing::error!(?error, %issue_tag_id, "failed to load issue tag"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue tag", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue tag not found"))?; ensure_issue_access(state.pool(), ctx.user.id, issue_tag.issue_id).await?; let response = IssueTagRepository::delete(state.pool(), issue_tag_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/issues.rs ================================================ use api_types::{ CreateIssueRequest, DeleteResponse, Issue, ListIssuesQuery, ListIssuesResponse, MutationResponse, NotificationPayload, NotificationType, SearchIssuesRequest, UpdateIssueRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, routing::post, }; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_project_access, }; use crate::{ AppState, auth::RequestContext, db::{ get_txid, issue_followers::IssueFollowerRepository, issues::IssueRepository, project_statuses::ProjectStatusRepository, }, mutation_definition::MutationBuilder, notifications::{ collect_issue_recipients, send_debounced_issue_notifications, send_issue_notifications, }, }; /// Mutation definition for Issue - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("issues") .list(list_issues) .get(get_issue) .create(create_issue) .update(update_issue) .delete(delete_issue) } /// Router for issue endpoints including bulk update pub fn router() -> axum::Router { mutation() .router() .route("/issues/search", post(search_issues)) .route("/issues/bulk", post(bulk_update_issues)) } async fn notify_issue_update_changes( state: &AppState, organization_id: Uuid, actor_user_id: Uuid, old_issue: &Issue, new_issue: &Issue, ) { let status_changed = old_issue.status_id != new_issue.status_id; let title_changed = old_issue.title != new_issue.title; let description_changed = old_issue.description != new_issue.description; let priority_changed = old_issue.priority != new_issue.priority; let needs_notification = status_changed || title_changed || description_changed || priority_changed; if !needs_notification { return; } let recipients = match collect_issue_recipients(state.pool(), organization_id, new_issue.id, actor_user_id) .await { Ok(recipients) => recipients, Err(error) => { tracing::warn!( ?error, issue_id = %new_issue.id, "failed to collect notification recipients" ); vec![] } }; if recipients.is_empty() { return; } if status_changed { let old_status_name = ProjectStatusRepository::find_by_id(state.pool(), old_issue.status_id) .await .ok() .flatten() .map(|s| s.name); let new_status_name = ProjectStatusRepository::find_by_id(state.pool(), new_issue.status_id) .await .ok() .flatten() .map(|s| s.name); send_issue_notifications( state.pool(), organization_id, actor_user_id, &recipients, new_issue, NotificationType::IssueStatusChanged, NotificationPayload { old_status_id: Some(old_issue.status_id), new_status_id: Some(new_issue.status_id), old_status_name, new_status_name, ..Default::default() }, None, Some(new_issue.id), ) .await; } if title_changed { send_debounced_issue_notifications( state.pool(), organization_id, actor_user_id, &recipients, new_issue, NotificationType::IssueTitleChanged, NotificationPayload { new_title: Some(new_issue.title.clone()), ..Default::default() }, None, Some(new_issue.id), ) .await; } if description_changed { send_debounced_issue_notifications( state.pool(), organization_id, actor_user_id, &recipients, new_issue, NotificationType::IssueDescriptionChanged, NotificationPayload::default(), None, Some(new_issue.id), ) .await; } if priority_changed { send_debounced_issue_notifications( state.pool(), organization_id, actor_user_id, &recipients, new_issue, NotificationType::IssuePriorityChanged, NotificationPayload { old_priority: old_issue.priority, new_priority: new_issue.priority, ..Default::default() }, None, Some(new_issue.id), ) .await; } } #[instrument( name = "issues.list_issues", skip(state, ctx), fields(project_id = %query.project_id, user_id = %ctx.user.id) )] async fn list_issues( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { let project_id = query.project_id; ensure_project_access(state.pool(), ctx.user.id, project_id).await?; let request = SearchIssuesRequest { project_id, status_id: None, status_ids: None, priority: None, parent_issue_id: None, search: None, simple_id: None, assignee_user_id: None, tag_id: None, tag_ids: None, sort_field: None, sort_direction: None, limit: None, offset: None, }; let response = IssueRepository::search(state.pool(), &request) .await .map_err(|error| { tracing::error!(?error, project_id = %project_id, "failed to list issues"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list issues") })?; Ok(Json(response)) } #[instrument( name = "issues.search_issues", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id) )] async fn search_issues( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?; let response = IssueRepository::search(state.pool(), &payload) .await .map_err(|error| { tracing::error!(?error, project_id = %payload.project_id, "failed to search issues"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to search issues") })?; Ok(Json(response)) } #[instrument( name = "issues.get_issue", skip(state, ctx), fields(issue_id = %issue_id, user_id = %ctx.user.id) )] async fn get_issue( State(state): State, Extension(ctx): Extension, Path(issue_id): Path, ) -> Result, ErrorResponse> { let issue = IssueRepository::find_by_id(state.pool(), issue_id) .await .map_err(|error| { tracing::error!(?error, %issue_id, "failed to load issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found"))?; ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?; Ok(Json(issue)) } #[instrument( name = "issues.create_issue", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id) )] async fn create_issue( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { let organization_id = ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?; let has_parent = payload.parent_issue_id.is_some(); let has_description = payload.description.is_some(); let priority = payload.priority; let parent_issue_id = payload.parent_issue_id; let response = IssueRepository::create( state.pool(), payload.id, payload.project_id, payload.status_id, payload.title, payload.description, payload.priority, payload.start_date, payload.target_date, payload.completed_at, payload.sort_order, payload.parent_issue_id, payload.parent_issue_sort_order, payload.extension_metadata, ctx.user.id, ) .await .map_err(|error| { tracing::error!(?error, "failed to create issue"); db_error(error, "failed to create issue") })?; // Auto-follow: the creator should receive notifications for all activity on this issue. if let Err(e) = IssueFollowerRepository::create(state.pool(), None, response.data.id, ctx.user.id).await { tracing::warn!(?e, issue_id = %response.data.id, "failed to auto-follow issue for creator"); } if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "issue_created", serde_json::json!({ "issue_id": response.data.id, "project_id": response.data.project_id, "organization_id": organization_id, "has_description": has_description, "has_parent": has_parent, "priority": format!("{:?}", priority), }), ); if let Some(parent_id) = parent_issue_id { analytics.track( ctx.user.id, "subtask_created", serde_json::json!({ "issue_id": response.data.id, "parent_issue_id": parent_id, "project_id": response.data.project_id, "organization_id": organization_id, }), ); } } Ok(Json(response)) } #[instrument( name = "issues.update_issue", skip(state, ctx, payload), fields(issue_id = %issue_id, user_id = %ctx.user.id) )] async fn update_issue( State(state): State, Extension(ctx): Extension, Path(issue_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let issue = IssueRepository::find_by_id(state.pool(), issue_id) .await .map_err(|error| { tracing::error!(?error, %issue_id, "failed to load issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found"))?; let organization_id = ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?; let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let data = IssueRepository::update( &mut *tx, issue_id, payload.status_id, payload.title, payload.description, payload.priority, payload.start_date, payload.target_date, payload.completed_at, payload.sort_order, payload.parent_issue_id, payload.parent_issue_sort_order, payload.extension_metadata, ) .await .map_err(|error| { tracing::error!(?error, "failed to update issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; notify_issue_update_changes(&state, organization_id, ctx.user.id, &issue, &data).await; Ok(Json(MutationResponse { data, txid })) } #[instrument( name = "issues.delete_issue", skip(state, ctx), fields(issue_id = %issue_id, user_id = %ctx.user.id) )] async fn delete_issue( State(state): State, Extension(ctx): Extension, Path(issue_id): Path, ) -> Result, ErrorResponse> { let issue = IssueRepository::find_by_id(state.pool(), issue_id) .await .map_err(|error| { tracing::error!(?error, %issue_id, "failed to load issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load issue") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found"))?; let organization_id = ensure_project_access(state.pool(), ctx.user.id, issue.project_id).await?; let recipients = match collect_issue_recipients( state.pool(), organization_id, issue.id, ctx.user.id, ) .await { Ok(recipients) => recipients, Err(error) => { tracing::warn!( ?error, issue_id = %issue.id, "failed to collect notification recipients" ); vec![] } }; let response = IssueRepository::delete(state.pool(), issue_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; send_issue_notifications( state.pool(), organization_id, ctx.user.id, &recipients, &issue, NotificationType::IssueDeleted, NotificationPayload::default(), None, None, ) .await; Ok(Json(response)) } // ============================================================================= // Bulk Update // ============================================================================= #[derive(Debug, Deserialize)] pub struct BulkUpdateIssueItem { pub id: Uuid, #[serde(flatten)] pub changes: UpdateIssueRequest, } #[derive(Debug, Deserialize)] pub struct BulkUpdateIssuesRequest { pub updates: Vec, } #[derive(Debug, Serialize)] pub struct BulkUpdateIssuesResponse { pub data: Vec, pub txid: i64, } #[instrument( name = "issues.bulk_update", skip(state, ctx, payload), fields(user_id = %ctx.user.id, count = payload.updates.len()) )] async fn bulk_update_issues( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { if payload.updates.is_empty() { return Ok(Json(BulkUpdateIssuesResponse { data: vec![], txid: 0, })); } // Get first issue to determine project_id for access check let first_issue = IssueRepository::find_by_id(state.pool(), payload.updates[0].id) .await .map_err(|error| { tracing::error!(?error, "failed to find first issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find issue") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found"))?; let project_id = first_issue.project_id; let organization_id = ensure_project_access(state.pool(), ctx.user.id, project_id).await?; let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let mut results = Vec::with_capacity(payload.updates.len()); let mut notification_pairs = Vec::with_capacity(payload.updates.len()); for item in payload.updates { // Verify issue belongs to the same project let issue = IssueRepository::find_by_id(&mut *tx, item.id) .await .map_err(|error| { tracing::error!(?error, issue_id = %item.id, "failed to find issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find issue") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found"))?; if issue.project_id != project_id { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "all issues must belong to the same project", )); } // Update the issue let updated = IssueRepository::update( &mut *tx, item.id, item.changes.status_id, item.changes.title, item.changes.description, item.changes.priority, item.changes.start_date, item.changes.target_date, item.changes.completed_at, item.changes.sort_order, item.changes.parent_issue_id, item.changes.parent_issue_sort_order, item.changes.extension_metadata, ) .await .map_err(|error| { tracing::error!(?error, issue_id = %item.id, "failed to update issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to update issue") })?; notification_pairs.push((issue, updated.clone())); results.push(updated); } let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; for (old_issue, new_issue) in ¬ification_pairs { notify_issue_update_changes(&state, organization_id, ctx.user.id, old_issue, new_issue) .await; } Ok(Json(BulkUpdateIssuesResponse { data: results, txid, })) } ================================================ FILE: crates/remote/src/routes/migration.rs ================================================ use std::collections::HashSet; use api_types::{ BulkMigrateRequest, BulkMigrateResponse, MigrateIssueRequest, MigrateProjectRequest, MigratePullRequestRequest, MigrateWorkspaceRequest, }; use axum::{ Json, Router, extract::{Extension, State}, http::StatusCode, routing::post, }; use tracing::instrument; use super::{ error::ErrorResponse, organization_members::{ensure_issue_access, ensure_member_access, ensure_project_access}, }; use crate::{AppState, auth::RequestContext, db::migration::MigrationRepository}; pub fn router() -> Router { Router::new() .route("/migration/projects", post(migrate_projects)) .route("/migration/issues", post(migrate_issues)) .route("/migration/pull_requests", post(migrate_pull_requests)) .route("/migration/workspaces", post(migrate_workspaces)) } #[instrument(name = "migration.projects", skip(state, ctx, payload))] async fn migrate_projects( State(state): State, Extension(ctx): Extension, Json(payload): Json>, ) -> Result, ErrorResponse> { let org_ids: HashSet<_> = payload .items .iter() .map(|item| item.organization_id) .collect(); for org_id in org_ids { ensure_member_access(state.pool(), org_id, ctx.user.id).await?; } let ids = MigrationRepository::bulk_create_projects(state.pool(), payload.items) .await .map_err(|error| { tracing::error!(?error, "failed to migrate projects"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) })?; Ok(Json(BulkMigrateResponse { ids })) } #[instrument(name = "migration.issues", skip(state, ctx, payload))] async fn migrate_issues( State(state): State, Extension(ctx): Extension, Json(payload): Json>, ) -> Result, ErrorResponse> { let project_ids: HashSet<_> = payload.items.iter().map(|item| item.project_id).collect(); for project_id in project_ids { ensure_project_access(state.pool(), ctx.user.id, project_id).await?; } let ids = MigrationRepository::bulk_create_issues(state.pool(), payload.items) .await .map_err(|error| { tracing::error!(?error, "failed to migrate issues"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) })?; Ok(Json(BulkMigrateResponse { ids })) } #[instrument(name = "migration.pull_requests", skip(state, ctx, payload))] async fn migrate_pull_requests( State(state): State, Extension(ctx): Extension, Json(payload): Json>, ) -> Result, ErrorResponse> { let issue_ids: HashSet<_> = payload.items.iter().map(|item| item.issue_id).collect(); for issue_id in issue_ids { ensure_issue_access(state.pool(), ctx.user.id, issue_id).await?; } let ids = MigrationRepository::bulk_create_pull_requests(state.pool(), payload.items) .await .map_err(|error| { tracing::error!(?error, "failed to migrate pull requests"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) })?; Ok(Json(BulkMigrateResponse { ids })) } #[instrument(name = "migration.workspaces", skip(state, ctx, payload))] async fn migrate_workspaces( State(state): State, Extension(ctx): Extension, Json(payload): Json>, ) -> Result, ErrorResponse> { let project_ids: HashSet<_> = payload.items.iter().map(|item| item.project_id).collect(); for project_id in project_ids { ensure_project_access(state.pool(), ctx.user.id, project_id).await?; } let ids = MigrationRepository::bulk_create_workspaces(state.pool(), ctx.user.id, payload.items) .await .map_err(|error| { tracing::error!(?error, "failed to migrate workspaces"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) })?; Ok(Json(BulkMigrateResponse { ids })) } ================================================ FILE: crates/remote/src/routes/mod.rs ================================================ use axum::{Json, Router, http::header::HeaderName, middleware, routing::get}; use serde::Serialize; use tower_http::{ compression::CompressionLayer, cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}, request_id::{MakeRequestUuid, PropagateRequestIdLayer, RequestId, SetRequestIdLayer}, services::{ServeDir, ServeFile}, trace::{DefaultOnFailure, TraceLayer}, }; use tracing::{Level, Span, field}; use crate::{AppState, auth::require_session}; #[cfg(feature = "vk-billing")] mod billing; #[cfg(not(feature = "vk-billing"))] mod billing { use axum::Router; use crate::AppState; pub fn public_router() -> Router { Router::new() } pub fn protected_router() -> Router { Router::new() } } pub mod attachments; pub(crate) mod electric_proxy; pub(crate) mod error; mod github_app; pub mod hosts; mod identity; pub mod issue_assignees; pub mod issue_comment_reactions; pub mod issue_comments; pub mod issue_followers; pub mod issue_relationships; pub mod issue_tags; pub mod issues; mod migration; pub mod notifications; mod oauth; pub(crate) mod organization_members; mod organizations; pub mod project_statuses; pub mod projects; mod pull_requests; mod review; pub mod tags; mod tokens; mod workspaces; pub fn router(state: AppState) -> Router { let trace_layer = TraceLayer::new_for_http() .make_span_with(|request: &axum::http::Request<_>| { let request_id = request .extensions() .get::() .and_then(|id| id.header_value().to_str().ok()); let is_health = request.uri().path() == "/health"; let span = if is_health { tracing::trace_span!( "http_request", method = %request.method(), uri = %request.uri(), request_id = field::Empty, user_id = field::Empty ) } else { tracing::debug_span!( "http_request", method = %request.method(), uri = %request.uri(), request_id = field::Empty, user_id = field::Empty ) }; if let Some(request_id) = request_id { span.record("request_id", field::display(request_id)); } span }) .on_response( |response: &axum::http::Response<_>, latency: std::time::Duration, span: &Span| { if span.is_disabled() { return; } let status = response.status().as_u16(); let latency_ms = latency.as_millis(); if status >= 500 { tracing::error!(status, latency_ms, "server error"); } else if status >= 400 { tracing::warn!(status, latency_ms, "client error"); } else { tracing::debug!(status, latency_ms, "request completed"); } }, ) .on_failure(DefaultOnFailure::new().level(Level::ERROR)); let v1_public = Router::::new() .route("/health", get(health)) .merge(oauth::public_router()) .merge(organization_members::public_router()) .merge(tokens::public_router()) .merge(review::public_router()) .merge(github_app::public_router()) .merge(billing::public_router()); let v1_protected = Router::::new() .merge(identity::router()) .merge(hosts::router()) .merge(projects::router()) .merge(organizations::router()) .merge(organization_members::protected_router()) .merge(oauth::protected_router()) .merge(electric_proxy::router()) .merge(github_app::protected_router()) .merge(project_statuses::router()) .merge(tags::router()) .merge(issue_comments::router()) .merge(issue_comment_reactions::router()) .merge(issues::router()) .merge(issue_assignees::router()) .merge(attachments::router()) .merge(issue_followers::router()) .merge(issue_tags::router()) .merge(issue_relationships::router()) .merge(pull_requests::router()) .merge(notifications::router()) .merge(workspaces::router()) .merge(billing::protected_router()) .merge(migration::router()) .layer(middleware::from_fn_with_state( state.clone(), require_session, )); let static_dir = "/srv/static"; let spa = ServeDir::new(static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))); Router::::new() .nest("/v1", v1_public) .nest("/v1", v1_protected) .fallback_service(spa) .layer(CompressionLayer::new()) .layer(middleware::from_fn( crate::middleware::version::add_version_headers, )) .layer( CorsLayer::new() .allow_origin(AllowOrigin::mirror_request()) .allow_methods(AllowMethods::mirror_request()) .allow_headers(AllowHeaders::mirror_request()) .allow_credentials(true), ) .layer(trace_layer) .layer(PropagateRequestIdLayer::new(HeaderName::from_static( "x-request-id", ))) .layer(SetRequestIdLayer::new( HeaderName::from_static("x-request-id"), MakeRequestUuid {}, )) .with_state(state) } #[derive(Serialize)] struct HealthResponse { status: &'static str, version: &'static str, } async fn health() -> Json { Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), }) } /// Collect all mutation definitions for TypeScript generation. pub fn all_mutation_definitions() -> Vec { vec![ projects::mutation().definition(), notifications::mutation().definition(), tags::mutation().definition(), project_statuses::mutation().definition(), issues::mutation().definition(), issue_assignees::mutation().definition(), issue_followers::mutation().definition(), issue_tags::mutation().definition(), issue_relationships::mutation().definition(), issue_comments::mutation().definition(), issue_comment_reactions::mutation().definition(), ] } ================================================ FILE: crates/remote/src/routes/notifications.rs ================================================ use api_types::{DeleteResponse, MutationResponse, Notification, UpdateNotificationRequest}; use axum::{ Json, Router, extract::{Extension, Path, Query, State}, http::StatusCode, routing::post, }; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use super::error::ErrorResponse; use crate::{ AppState, auth::RequestContext, db::{get_txid, notifications::NotificationRepository}, mutation_definition::{MutationBuilder, NoCreate}, }; #[derive(Debug, Serialize)] pub struct ListNotificationsResponse { pub notifications: Vec, } #[derive(Debug, Deserialize)] pub struct ListNotificationsQuery { #[serde(default)] pub include_dismissed: bool, } #[derive(Debug, Deserialize)] pub struct BulkUpdateNotificationItem { pub id: Uuid, #[serde(flatten)] pub changes: UpdateNotificationRequest, } #[derive(Debug, Deserialize)] pub struct BulkUpdateNotificationsRequest { pub updates: Vec, } #[derive(Debug, Serialize)] pub struct BulkUpdateNotificationsResponse { pub data: Vec, pub txid: i64, } pub fn mutation() -> MutationBuilder { MutationBuilder::new("notifications") .list(list_notifications) .get(get_notification) .update(update_notification) .delete(delete_notification) } pub fn router() -> Router { mutation() .router() .route("/notifications/bulk", post(bulk_update_notifications)) } #[instrument( name = "notifications.list", skip(state, ctx), fields(user_id = %ctx.user.id) )] async fn list_notifications( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { let notifications = NotificationRepository::list_by_user(state.pool(), ctx.user.id, query.include_dismissed) .await .map_err(|error| { tracing::error!(?error, "failed to list notifications"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list notifications", ) })?; Ok(Json(ListNotificationsResponse { notifications })) } #[instrument( name = "notifications.get", skip(state, ctx), fields(notification_id = %notification_id, user_id = %ctx.user.id) )] async fn get_notification( State(state): State, Extension(ctx): Extension, Path(notification_id): Path, ) -> Result, ErrorResponse> { let notification = NotificationRepository::find_by_id(state.pool(), notification_id) .await .map_err(|error| { tracing::error!(?error, %notification_id, "failed to load notification"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load notification", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "notification not found"))?; if notification.user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::NOT_FOUND, "notification not found", )); } Ok(Json(notification)) } #[instrument( name = "notifications.update", skip(state, ctx, payload), fields(notification_id = %notification_id, user_id = %ctx.user.id) )] async fn update_notification( State(state): State, Extension(ctx): Extension, Path(notification_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let mut tx = state.pool().begin().await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let existing = NotificationRepository::find_by_id(&mut *tx, notification_id) .await .map_err(|error| { tracing::error!(?error, %notification_id, "failed to load notification"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load notification", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "notification not found"))?; if existing.user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::NOT_FOUND, "notification not found", )); } let data = NotificationRepository::update(&mut *tx, notification_id, payload.seen) .await .map_err(|error| { tracing::error!(?error, "failed to update notification"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(MutationResponse { data, txid })) } #[instrument( name = "notifications.delete", skip(state, ctx), fields(notification_id = %notification_id, user_id = %ctx.user.id) )] async fn delete_notification( State(state): State, Extension(ctx): Extension, Path(notification_id): Path, ) -> Result, ErrorResponse> { let mut tx = state.pool().begin().await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let notification = NotificationRepository::find_by_id(&mut *tx, notification_id) .await .map_err(|error| { tracing::error!(?error, %notification_id, "failed to load notification"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load notification", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "notification not found"))?; if notification.user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::NOT_FOUND, "notification not found", )); } NotificationRepository::delete(&mut *tx, notification_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete notification"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(DeleteResponse { txid })) } #[instrument( name = "notifications.bulk_update", skip(state, ctx, payload), fields(user_id = %ctx.user.id, count = payload.updates.len()) )] async fn bulk_update_notifications( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { if payload.updates.is_empty() { return Ok(Json(BulkUpdateNotificationsResponse { data: vec![], txid: 0, })); } let mut tx = state.pool().begin().await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let first_notification = NotificationRepository::find_by_id(&mut *tx, payload.updates[0].id) .await .map_err(|error| { tracing::error!(?error, "failed to find first notification"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find notification", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "notification not found"))?; let user_id = first_notification.user_id; if user_id != ctx.user.id { return Err(ErrorResponse::new( StatusCode::NOT_FOUND, "notification not found", )); } let mut results = Vec::with_capacity(payload.updates.len()); for item in payload.updates { let existing = NotificationRepository::find_by_id(&mut *tx, item.id) .await .map_err(|error| { tracing::error!(?error, notification_id = %item.id, "failed to find notification"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find notification", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "notification not found"))?; if existing.user_id != user_id { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "all notifications must belong to the same user", )); } let updated = NotificationRepository::update(&mut *tx, item.id, item.changes.seen) .await .map_err(|error| { tracing::error!(?error, notification_id = %item.id, "failed to update notification"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; results.push(updated); } let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(BulkUpdateNotificationsResponse { data: results, txid, })) } ================================================ FILE: crates/remote/src/routes/oauth.rs ================================================ use std::borrow::Cow; use api_types::{ HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest, HandoffRedeemResponse, ProfileResponse, ProviderProfile, }; use axum::{ Json, Router, extract::{Extension, Path, Query, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde::Deserialize; use tracing::warn; use url::Url; use uuid::Uuid; use crate::{ AppState, audit::{self, AuditAction, AuditEvent}, auth::{CallbackResult, HandoffError, RequestContext}, db::{oauth::OAuthHandoffError, oauth_accounts::OAuthAccountRepository}, }; pub fn public_router() -> Router { Router::new() .route("/oauth/web/init", post(web_init)) .route("/oauth/web/redeem", post(web_redeem)) .route("/oauth/{provider}/start", get(authorize_start)) .route("/oauth/{provider}/callback", get(authorize_callback)) } pub fn protected_router() -> Router { Router::new() .route("/profile", get(profile)) .route("/oauth/logout", post(logout)) } pub async fn web_init( State(state): State, Json(payload): Json, ) -> Response { let handoff = state.handoff(); match handoff .initiate( &payload.provider, &payload.return_to, &payload.app_challenge, ) .await { Ok(result) => ( StatusCode::OK, Json(HandoffInitResponse { handoff_id: result.handoff_id, authorize_url: result.authorize_url, }), ) .into_response(), Err(error) => init_error_response(error), } } pub async fn web_redeem( State(state): State, Json(payload): Json, ) -> Response { let handoff = state.handoff(); match handoff .redeem(payload.handoff_id, &payload.app_code, &payload.app_verifier) .await { Ok(result) => { if let Some(analytics) = state.analytics() { analytics.track( result.user_id, "$identify", serde_json::json!({ "email": result.email }), ); } audit::emit( AuditEvent::system(AuditAction::AuthLogin) .user(result.user_id, None) .resource("auth_session", None) .http("POST", "/v1/oauth/web/redeem", 200) .description("User logged in via OAuth"), ); ( StatusCode::OK, Json(HandoffRedeemResponse { access_token: result.access_token, refresh_token: result.refresh_token, }), ) .into_response() } Err(error) => redeem_error_response(error), } } #[derive(Debug, Deserialize)] pub struct StartQuery { handoff_id: Uuid, } pub async fn authorize_start( State(state): State, Path(provider): Path, Query(query): Query, ) -> Response { let handoff = state.handoff(); match handoff.authorize_url(&provider, query.handoff_id).await { Ok(url) => Redirect::temporary(&url).into_response(), Err(error) => { let (status, message) = classify_handoff_error(&error); ( status, format!("OAuth authorization failed: {}", message.into_owned()), ) .into_response() } } } #[derive(Debug, Deserialize)] pub struct CallbackQuery { state: Option, code: Option, error: Option, } pub async fn authorize_callback( State(state): State, Path(provider): Path, Query(query): Query, ) -> Response { let handoff = state.handoff(); match handoff .handle_callback( &provider, query.state.as_deref(), query.code.as_deref(), query.error.as_deref(), ) .await { Ok(CallbackResult::Success { handoff_id, return_to, app_code, }) => match append_query_params(&return_to, Some(handoff_id), Some(&app_code), None) { Ok(url) => Redirect::temporary(url.as_str()).into_response(), Err(err) => ( StatusCode::BAD_REQUEST, format!("Invalid return_to URL: {err}"), ) .into_response(), }, Ok(CallbackResult::Error { handoff_id, return_to, error, }) => { if let Some(url) = return_to { match append_query_params(&url, handoff_id, None, Some(&error)) { Ok(url) => Redirect::temporary(url.as_str()).into_response(), Err(err) => ( StatusCode::BAD_REQUEST, format!("Invalid return_to URL: {err}"), ) .into_response(), } } else { ( StatusCode::BAD_REQUEST, format!("OAuth authorization failed: {error}"), ) .into_response() } } Err(error) => { let (status, message) = classify_handoff_error(&error); ( status, format!("OAuth authorization failed: {}", message.into_owned()), ) .into_response() } } } pub async fn profile( State(state): State, Extension(ctx): Extension, ) -> Json { let repo = OAuthAccountRepository::new(state.pool()); let providers = repo .list_by_user(ctx.user.id) .await .unwrap_or_default() .into_iter() .map(|account| ProviderProfile { provider: account.provider, username: account.username, display_name: account.display_name, email: account.email, avatar_url: account.avatar_url, }) .collect(); Json(ProfileResponse { user_id: ctx.user.id, username: ctx.user.username.clone(), email: ctx.user.email.clone(), providers, }) } pub async fn logout( State(state): State, Extension(ctx): Extension, ) -> Response { use crate::db::auth::{AuthSessionError, AuthSessionRepository}; let repo = AuthSessionRepository::new(state.pool()); let (response, status) = match repo.revoke(ctx.session_id).await { Ok(_) | Err(AuthSessionError::NotFound) => (StatusCode::NO_CONTENT.into_response(), 204u16), Err(AuthSessionError::Database(error)) => { warn!(?error, session_id = %ctx.session_id, "failed to revoke auth session"); (StatusCode::INTERNAL_SERVER_ERROR.into_response(), 500u16) } Err(error) => { warn!(?error, session_id = %ctx.session_id, "failed to revoke auth session"); (StatusCode::INTERNAL_SERVER_ERROR.into_response(), 500u16) } }; audit::emit( AuditEvent::from_request(&ctx, AuditAction::AuthLogout) .resource("auth_session", Some(ctx.session_id)) .http("POST", "/v1/oauth/logout", status) .description("User logged out"), ); response } fn init_error_response(error: HandoffError) -> Response { match &error { HandoffError::Provider(err) => warn!(?err, "provider error during oauth init"), HandoffError::Database(err) => warn!(?err, "database error during oauth init"), HandoffError::Authorization(err) => warn!(?err, "authorization error during oauth init"), HandoffError::Identity(err) => warn!(?err, "identity error during oauth init"), HandoffError::OAuthAccount(err) => warn!(?err, "account error during oauth init"), _ => {} } let (status, code) = classify_handoff_error(&error); let code = code.into_owned(); (status, Json(serde_json::json!({ "error": code }))).into_response() } fn redeem_error_response(error: HandoffError) -> Response { match &error { HandoffError::Provider(err) => warn!(?err, "provider error during oauth redeem"), HandoffError::Database(err) => warn!(?err, "database error during oauth redeem"), HandoffError::Authorization(err) => warn!(?err, "authorization error during oauth redeem"), HandoffError::Identity(err) => warn!(?err, "identity error during oauth redeem"), HandoffError::OAuthAccount(err) => warn!(?err, "account error during oauth redeem"), HandoffError::Session(err) => warn!(?err, "session error during oauth redeem"), HandoffError::Jwt(err) => warn!(?err, "jwt error during oauth redeem"), _ => {} } let (status, code) = classify_handoff_error(&error); let code = code.into_owned(); (status, Json(serde_json::json!({ "error": code }))).into_response() } fn classify_handoff_error(error: &HandoffError) -> (StatusCode, Cow<'_, str>) { match error { HandoffError::UnsupportedProvider(_) => ( StatusCode::BAD_REQUEST, Cow::Borrowed("unsupported_provider"), ), HandoffError::InvalidReturnUrl(_) => { (StatusCode::BAD_REQUEST, Cow::Borrowed("invalid_return_url")) } HandoffError::InvalidChallenge => { (StatusCode::BAD_REQUEST, Cow::Borrowed("invalid_challenge")) } HandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed("not_found")), HandoffError::Expired => (StatusCode::GONE, Cow::Borrowed("expired")), HandoffError::Denied => (StatusCode::FORBIDDEN, Cow::Borrowed("access_denied")), HandoffError::Failed(reason) => (StatusCode::BAD_REQUEST, Cow::Owned(reason.clone())), HandoffError::Provider(_) => (StatusCode::BAD_GATEWAY, Cow::Borrowed("provider_error")), HandoffError::Database(_) | HandoffError::Identity(_) | HandoffError::OAuthAccount(_) | HandoffError::Session(_) | HandoffError::Jwt(_) => ( StatusCode::INTERNAL_SERVER_ERROR, Cow::Borrowed("internal_error"), ), HandoffError::Authorization(auth_err) => match auth_err { OAuthHandoffError::NotAuthorized => (StatusCode::GONE, Cow::Borrowed("not_authorized")), OAuthHandoffError::AlreadyRedeemed => { (StatusCode::GONE, Cow::Borrowed("already_redeemed")) } OAuthHandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed("not_found")), OAuthHandoffError::Database(_) => ( StatusCode::INTERNAL_SERVER_ERROR, Cow::Borrowed("internal_error"), ), }, } } fn append_query_params( base: &str, handoff_id: Option, app_code: Option<&str>, error: Option<&str>, ) -> Result { let mut url = Url::parse(base)?; { let mut qp = url.query_pairs_mut(); if let Some(id) = handoff_id { qp.append_pair("handoff_id", &id.to_string()); } if let Some(code) = app_code { qp.append_pair("app_code", code); } if let Some(error) = error { qp.append_pair("error", error); } } Ok(url) } ================================================ FILE: crates/remote/src/routes/organization_members.rs ================================================ use api_types::{ ListMembersResponse, MemberRole, OrganizationMemberWithProfile, RevokeInvitationRequest, UpdateMemberRoleRequest, UpdateMemberRoleResponse, }; use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, patch, post}, }; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tracing::warn; use uuid::Uuid; use super::error::{ErrorResponse, membership_error}; use crate::{ AppState, audit::{self, AuditAction, AuditEvent}, auth::RequestContext, db::{ identity_errors::IdentityError, invitations::{Invitation, InvitationRepository}, issue_comments::IssueCommentRepository, issues::IssueRepository, organization_members, organizations::OrganizationRepository, projects::ProjectRepository, }, }; pub fn public_router() -> Router { Router::new().route("/invitations/{token}", get(get_invitation)) } pub fn protected_router() -> Router { Router::new() .route( "/organizations/{org_id}/invitations", post(create_invitation), ) .route("/organizations/{org_id}/invitations", get(list_invitations)) .route( "/organizations/{org_id}/invitations/revoke", post(revoke_invitation), ) .route("/invitations/{token}/accept", post(accept_invitation)) .route("/organizations/{org_id}/members", get(list_members)) .route( "/organizations/{org_id}/members/{user_id}", delete(remove_member), ) .route( "/organizations/{org_id}/members/{user_id}/role", patch(update_member_role), ) } #[derive(Debug, Deserialize)] pub struct CreateInvitationRequest { pub email: String, pub role: MemberRole, } #[derive(Debug, Serialize)] pub struct CreateInvitationResponse { pub invitation: Invitation, } #[derive(Debug, Serialize)] pub struct ListInvitationsResponse { pub invitations: Vec, } #[derive(Debug, Serialize)] pub struct GetInvitationResponse { pub id: Uuid, pub organization_slug: String, pub organization_name: String, pub role: MemberRole, pub expires_at: chrono::DateTime, } #[derive(Debug, Serialize)] pub struct AcceptInvitationResponse { pub organization_id: String, pub organization_slug: String, pub role: MemberRole, } pub async fn create_invitation( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { let session_id = ctx.session_id; let user = ctx.user; let org_repo = OrganizationRepository::new(&state.pool); let invitation_repo = InvitationRepository::new(&state.pool); ensure_admin_access(&state.pool, org_id, user.id).await?; state .billing() .can_add_member(org_id) .await .map_err(|e| e.to_error_response("Cannot invite more members"))?; let token = Uuid::new_v4().to_string(); let expires_at = Utc::now() + Duration::days(7); let invitation = invitation_repo .create_invitation( org_id, user.id, &payload.email, payload.role, expires_at, &token, ) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; let organization = org_repo.fetch_organization(org_id).await.map_err(|_| { ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch organization", ) })?; let accept_url = format!( "{}/invitations/{}/accept", state.server_public_base_url, token ); state .mailer .send_org_invitation( &organization.name, &payload.email, &accept_url, payload.role, user.username.as_deref(), ) .await; audit::emit( AuditEvent::system(AuditAction::MemberInvite) .user(user.id, Some(session_id)) .resource("invitation", Some(invitation.id)) .organization(org_id) .http( "POST", format!("/v1/organizations/{org_id}/invitations"), 201, ) .description(format!("Invited member with role {:?}", payload.role)), ); if let Some(analytics) = state.analytics() { analytics.track( user.id, "invitation_created", serde_json::json!({ "invitation_id": invitation.id, "organization_id": org_id, "role": format!("{:?}", payload.role), }), ); } Ok(( StatusCode::CREATED, Json(CreateInvitationResponse { invitation }), )) } pub async fn list_invitations( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { let user = ctx.user; let invitation_repo = InvitationRepository::new(&state.pool); ensure_admin_access(&state.pool, org_id, user.id).await?; let invitations = invitation_repo .list_invitations(org_id, user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; Ok(Json(ListInvitationsResponse { invitations })) } pub async fn get_invitation( State(state): State, Path(token): Path, ) -> Result { let invitation_repo = InvitationRepository::new(&state.pool); let invitation = invitation_repo .get_invitation_by_token(&token) .await .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found"))?; let org_repo = OrganizationRepository::new(&state.pool); let org = org_repo .fetch_organization(invitation.organization_id) .await .map_err(|_| { ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch organization", ) })?; Ok(Json(GetInvitationResponse { id: invitation.id, organization_slug: org.slug, organization_name: org.name, role: invitation.role, expires_at: invitation.expires_at, })) } pub async fn revoke_invitation( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { let invitation_repo = InvitationRepository::new(&state.pool); ensure_admin_access(&state.pool, org_id, ctx.user.id).await?; invitation_repo .revoke_invitation(org_id, payload.invitation_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; audit::emit( AuditEvent::from_request(&ctx, AuditAction::MemberRevokeInvite) .resource("invitation", Some(payload.invitation_id)) .organization(org_id) .http( "POST", format!("/v1/organizations/{org_id}/invitations/revoke"), 204, ) .description("Revoked invitation"), ); Ok(StatusCode::NO_CONTENT) } pub async fn accept_invitation( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(token): Path, ) -> Result { let session_id = ctx.session_id; let user = ctx.user; let invitation_repo = InvitationRepository::new(&state.pool); let (org, role) = invitation_repo .accept_invitation(&token, user.id, state.billing()) .await .map_err(|e| match e { IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found") } #[cfg(feature = "vk-billing")] IdentityError::Billing(billing_err) => { billing_err.to_error_response("Cannot accept invitation") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; audit::emit( AuditEvent::system(AuditAction::MemberAcceptInvite) .user(user.id, Some(session_id)) .resource("organization_member", None) .organization(org.id) .http("POST", format!("/v1/invitations/{token}/accept"), 200) .description(format!("Accepted invitation with role {role:?}")), ); if let Some(analytics) = state.analytics() { analytics.track( user.id, "invitation_accepted", serde_json::json!({ "organization_id": org.id, "role": format!("{:?}", role), }), ); } Ok(Json(AcceptInvitationResponse { organization_id: org.id.to_string(), organization_slug: org.slug, role, })) } pub async fn list_members( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { let user = ctx.user; ensure_member_access(&state.pool, org_id, user.id).await?; let members = sqlx::query_as!( OrganizationMemberWithProfile, r#" SELECT omm.user_id AS "user_id!: Uuid", omm.role AS "role!: MemberRole", omm.joined_at AS "joined_at!", u.first_name AS "first_name?", u.last_name AS "last_name?", u.username AS "username?", u.email AS "email?", oa.avatar_url AS "avatar_url?" FROM organization_member_metadata omm INNER JOIN users u ON omm.user_id = u.id LEFT JOIN LATERAL ( SELECT avatar_url FROM oauth_accounts WHERE user_id = omm.user_id ORDER BY created_at ASC LIMIT 1 ) oa ON true WHERE omm.organization_id = $1 ORDER BY omm.joined_at ASC "#, org_id ) .fetch_all(&state.pool) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; Ok(Json(ListMembersResponse { members })) } pub async fn remove_member( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path((org_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { let session_id = ctx.session_id; let user = ctx.user; if user.id == user_id { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Cannot remove yourself", )); } let org_repo = OrganizationRepository::new(&state.pool); if org_repo .is_personal(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found"))? { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Cannot modify members of a personal organization", )); } ensure_admin_access(&state.pool, org_id, user.id).await?; let mut tx = crate::db::begin_tx(&state.pool) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; let target = sqlx::query!( r#" SELECT role AS "role!: MemberRole" FROM organization_member_metadata WHERE organization_id = $1 AND user_id = $2 FOR UPDATE "#, org_id, user_id ) .fetch_optional(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "Member not found"))?; if target.role == MemberRole::Admin { let admin_ids = sqlx::query_scalar!( r#" SELECT user_id FROM organization_member_metadata WHERE organization_id = $1 AND role = 'admin' FOR UPDATE "#, org_id ) .fetch_all(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; if admin_ids.len() == 1 && admin_ids[0] == user_id { return Err(ErrorResponse::new( StatusCode::CONFLICT, "Cannot remove the last admin", )); } } sqlx::query!( r#" DELETE FROM organization_member_metadata WHERE organization_id = $1 AND user_id = $2 "#, org_id, user_id ) .execute(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; tx.commit() .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; state.billing().on_member_count_changed(org_id).await; audit::emit( AuditEvent::system(AuditAction::MemberRemove) .user(user.id, Some(session_id)) .resource("organization_member", Some(user_id)) .organization(org_id) .http( "DELETE", format!("/v1/organizations/{org_id}/members/{user_id}"), 204, ) .description("Removed member from organization"), ); Ok(StatusCode::NO_CONTENT) } pub async fn update_member_role( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path((org_id, user_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result { let session_id = ctx.session_id; let user = ctx.user; if user.id == user_id && payload.role == MemberRole::Member { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Cannot demote yourself", )); } let org_repo = OrganizationRepository::new(&state.pool); if org_repo .is_personal(org_id) .await .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found"))? { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Cannot modify members of a personal organization", )); } ensure_admin_access(&state.pool, org_id, user.id).await?; let mut tx = crate::db::begin_tx(&state.pool) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; let target = sqlx::query!( r#" SELECT role AS "role!: MemberRole" FROM organization_member_metadata WHERE organization_id = $1 AND user_id = $2 FOR UPDATE "#, org_id, user_id ) .fetch_optional(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "Member not found"))?; if target.role == payload.role { return Ok(Json(UpdateMemberRoleResponse { user_id, role: payload.role, })); } if target.role == MemberRole::Admin && payload.role == MemberRole::Member { let admin_ids = sqlx::query_scalar!( r#" SELECT user_id FROM organization_member_metadata WHERE organization_id = $1 AND role = 'admin' FOR UPDATE "#, org_id ) .fetch_all(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; if admin_ids.len() == 1 && admin_ids[0] == user_id { return Err(ErrorResponse::new( StatusCode::CONFLICT, "Cannot demote the last admin", )); } } sqlx::query!( r#" UPDATE organization_member_metadata SET role = $3 WHERE organization_id = $1 AND user_id = $2 "#, org_id, user_id, payload.role as MemberRole ) .execute(&mut *tx) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; tx.commit() .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; audit::emit( AuditEvent::system(AuditAction::MemberRoleChange) .user(user.id, Some(session_id)) .resource("organization_member", Some(user_id)) .organization(org_id) .http( "PATCH", format!("/v1/organizations/{org_id}/members/{user_id}/role"), 200, ) .description(format!( "Changed member role to {role:?}", role = payload.role )), ); Ok(Json(UpdateMemberRoleResponse { user_id, role: payload.role, })) } pub(crate) async fn ensure_member_access( pool: &PgPool, organization_id: Uuid, user_id: Uuid, ) -> Result<(), ErrorResponse> { organization_members::assert_membership(pool, organization_id, user_id) .await .map_err(|err| membership_error(err, "Not a member of organization")) } pub(crate) async fn ensure_admin_access( pool: &PgPool, organization_id: Uuid, user_id: Uuid, ) -> Result<(), ErrorResponse> { OrganizationRepository::new(pool) .assert_admin(organization_id, user_id) .await .map_err(|err| membership_error(err, "Admin access required")) } pub(crate) async fn ensure_project_access( pool: &PgPool, user_id: Uuid, project_id: Uuid, ) -> Result { let organization_id = ProjectRepository::organization_id(pool, project_id) .await .map_err(|error| { tracing::error!(?error, %project_id, "failed to load project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? .ok_or_else(|| { warn!( %project_id, %user_id, "project not found for access check" ); ErrorResponse::new(StatusCode::NOT_FOUND, "project not found") })?; organization_members::assert_membership(pool, organization_id, user_id) .await .map_err(|err| { if let IdentityError::Database(error) = &err { tracing::error!( ?error, %organization_id, %project_id, "failed to authorize project membership" ); } else { warn!( ?err, %organization_id, %project_id, %user_id, "project access denied" ); } membership_error(err, "project not accessible") })?; Ok(organization_id) } pub(crate) async fn ensure_issue_access( pool: &PgPool, user_id: Uuid, issue_id: Uuid, ) -> Result { let organization_id = IssueRepository::organization_id(pool, issue_id) .await .map_err(|error| { tracing::error!(?error, %issue_id, "failed to load issue"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? .ok_or_else(|| { warn!( %issue_id, %user_id, "issue not found for access check" ); ErrorResponse::new(StatusCode::NOT_FOUND, "issue not found") })?; organization_members::assert_membership(pool, organization_id, user_id) .await .map_err(|err| { if let IdentityError::Database(error) = &err { tracing::error!( ?error, %organization_id, %issue_id, "failed to authorize issue access" ); } else { warn!( ?err, %organization_id, %issue_id, %user_id, "issue access denied" ); } membership_error(err, "issue not accessible") })?; Ok(organization_id) } pub(crate) async fn ensure_comment_access( pool: &PgPool, user_id: Uuid, comment_id: Uuid, ) -> Result { let comment = IssueCommentRepository::find_by_id(pool, comment_id) .await .map_err(|error| { tracing::error!(?error, %comment_id, "failed to load comment"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? .ok_or_else(|| { warn!( %comment_id, %user_id, "comment not found for access check" ); ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found") })?; ensure_issue_access(pool, user_id, comment.issue_id).await } ================================================ FILE: crates/remote/src/routes/organizations.rs ================================================ use api_types::{ CreateOrganizationRequest, CreateOrganizationResponse, GetOrganizationResponse, ListOrganizationsResponse, MemberRole, UpdateOrganizationRequest, }; use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, patch, post}, }; use uuid::Uuid; use super::error::ErrorResponse; use crate::{ AppState, auth::RequestContext, db::{ identity_errors::IdentityError, organization_members, organizations::OrganizationRepository, }, }; pub fn router() -> Router { Router::new() .route("/organizations", post(create_organization)) .route("/organizations", get(list_organizations)) .route("/organizations/{org_id}", get(get_organization)) .route("/organizations/{org_id}", patch(update_organization)) .route("/organizations/{org_id}", delete(delete_organization)) } pub async fn create_organization( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Json(payload): Json, ) -> Result { let name = payload.name.trim(); let slug = payload.slug.trim().to_lowercase(); if name.is_empty() || name.len() > 100 { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Organization name must be between 1 and 100 characters", )); } if slug.len() < 3 || slug.len() > 63 { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Organization slug must be between 3 and 63 characters", )); } if !slug .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Organization slug can only contain lowercase letters, numbers, hyphens, and underscores", )); } let org_repo = OrganizationRepository::new(&state.pool); let organization = org_repo .create_organization(name, &slug, ctx.user.id) .await .map_err(|e| match e { IdentityError::OrganizationConflict(msg) => { ErrorResponse::new(StatusCode::CONFLICT, msg) } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "organization_created", serde_json::json!({ "organization_id": organization.id, }), ); } Ok(( StatusCode::CREATED, Json(CreateOrganizationResponse { organization }), )) } pub async fn list_organizations( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, ) -> Result { let org_repo = OrganizationRepository::new(&state.pool); let organizations = org_repo .list_user_organizations(ctx.user.id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; Ok(Json(ListOrganizationsResponse { organizations })) } pub async fn get_organization( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { let org_repo = OrganizationRepository::new(&state.pool); organization_members::assert_membership(&state.pool, org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied"), })?; let organization = org_repo.fetch_organization(org_id).await.map_err(|_| { ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch organization", ) })?; let role = org_repo .check_user_role(org_id, ctx.user.id) .await .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? .unwrap_or(MemberRole::Member); let user_role = match role { MemberRole::Admin => "ADMIN", MemberRole::Member => "MEMBER", } .to_string(); Ok(Json(GetOrganizationResponse { organization, user_role, })) } pub async fn update_organization( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, Json(payload): Json, ) -> Result { let name = payload.name.trim(); if name.is_empty() || name.len() > 100 { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Organization name must be between 1 and 100 characters", )); } let org_repo = OrganizationRepository::new(&state.pool); let organization = org_repo .update_organization_name(org_id, ctx.user.id, name) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; Ok(Json(organization)) } pub async fn delete_organization( State(state): State, axum::extract::Extension(ctx): axum::extract::Extension, Path(org_id): Path, ) -> Result { let org_repo = OrganizationRepository::new(&state.pool); org_repo .delete_organization(org_id, ctx.user.id) .await .map_err(|e| match e { IdentityError::PermissionDenied => { ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") } IdentityError::CannotDeleteOrganization(msg) => { ErrorResponse::new(StatusCode::CONFLICT, msg) } IdentityError::NotFound => { ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") } _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), })?; Ok(StatusCode::NO_CONTENT) } ================================================ FILE: crates/remote/src/routes/project_statuses.rs ================================================ use api_types::{ CreateProjectStatusRequest, DeleteResponse, ListProjectStatusesQuery, ListProjectStatusesResponse, MutationResponse, ProjectStatus, UpdateProjectStatusRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, routing::post, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_project_access, }; use crate::{ AppState, auth::RequestContext, db::{get_txid, project_statuses::ProjectStatusRepository, types::is_valid_hsl_color}, mutation_definition::MutationBuilder, }; /// Mutation definition for ProjectStatus - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("project_statuses") .list(list_project_statuses) .get(get_project_status) .create(create_project_status) .update(update_project_status) .delete(delete_project_status) } /// Router for project status endpoints including bulk update pub fn router() -> axum::Router { mutation() .router() .route("/project_statuses/bulk", post(bulk_update_project_statuses)) } #[instrument( name = "project_statuses.list_project_statuses", skip(state, ctx), fields(project_id = %query.project_id, user_id = %ctx.user.id) )] async fn list_project_statuses( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let project_statuses = ProjectStatusRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list project statuses"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list project statuses", ) })?; Ok(Json(ListProjectStatusesResponse { project_statuses })) } #[instrument( name = "project_statuses.get_project_status", skip(state, ctx), fields(project_status_id = %project_status_id, user_id = %ctx.user.id) )] async fn get_project_status( State(state): State, Extension(ctx): Extension, Path(project_status_id): Path, ) -> Result, ErrorResponse> { let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id) .await .map_err(|error| { tracing::error!(?error, %project_status_id, "failed to load project status"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load project status", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?; Ok(Json(status)) } #[instrument( name = "project_statuses.create_project_status", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id) )] async fn create_project_status( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?; if !is_valid_hsl_color(&payload.color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let response = ProjectStatusRepository::create( state.pool(), payload.id, payload.project_id, payload.name, payload.color, payload.sort_order, payload.hidden, ) .await .map_err(|error| { tracing::error!(?error, "failed to create project status"); db_error(error, "failed to create project status") })?; Ok(Json(response)) } #[instrument( name = "project_statuses.update_project_status", skip(state, ctx, payload), fields(project_status_id = %project_status_id, user_id = %ctx.user.id) )] async fn update_project_status( State(state): State, Extension(ctx): Extension, Path(project_status_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id) .await .map_err(|error| { tracing::error!(?error, %project_status_id, "failed to load project status"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load project status", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?; if let Some(ref color) = payload.color && !is_valid_hsl_color(color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let response = ProjectStatusRepository::update( state.pool(), project_status_id, payload.name, payload.color, payload.sort_order, payload.hidden, ) .await .map_err(|error| { tracing::error!(?error, "failed to update project status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } #[instrument( name = "project_statuses.delete_project_status", skip(state, ctx), fields(project_status_id = %project_status_id, user_id = %ctx.user.id) )] async fn delete_project_status( State(state): State, Extension(ctx): Extension, Path(project_status_id): Path, ) -> Result, ErrorResponse> { let status = ProjectStatusRepository::find_by_id(state.pool(), project_status_id) .await .map_err(|error| { tracing::error!(?error, %project_status_id, "failed to load project status"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load project status", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; ensure_project_access(state.pool(), ctx.user.id, status.project_id).await?; let response = ProjectStatusRepository::delete(state.pool(), project_status_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete project status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } #[derive(Debug, Deserialize)] pub struct BulkUpdateProjectStatusItem { pub id: Uuid, #[serde(flatten)] pub changes: UpdateProjectStatusRequest, } #[derive(Debug, Deserialize)] pub struct BulkUpdateProjectStatusesRequest { pub updates: Vec, } #[derive(Debug, Serialize)] pub struct BulkUpdateProjectStatusesResponse { pub data: Vec, pub txid: i64, } #[instrument( name = "project_statuses.bulk_update", skip(state, ctx, payload), fields(user_id = %ctx.user.id, count = payload.updates.len()) )] async fn bulk_update_project_statuses( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { if payload.updates.is_empty() { return Ok(Json(BulkUpdateProjectStatusesResponse { data: vec![], txid: 0, })); } // Get first status to determine project_id for access check let first_status = ProjectStatusRepository::find_by_id(state.pool(), payload.updates[0].id) .await .map_err(|error| { tracing::error!(?error, "failed to find first project status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find status") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; let project_id = first_status.project_id; ensure_project_access(state.pool(), ctx.user.id, project_id).await?; let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let mut results = Vec::with_capacity(payload.updates.len()); for item in payload.updates { // Verify status belongs to the same project let status = ProjectStatusRepository::find_by_id(state.pool(), item.id) .await .map_err(|error| { tracing::error!(?error, status_id = %item.id, "failed to find project status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find status") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; if status.project_id != project_id { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "all statuses must belong to the same project", )); } // Validate color if provided if let Some(ref color) = item.changes.color && !is_valid_hsl_color(color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } // Update the status within the transaction let updated = sqlx::query_as!( ProjectStatus, r#" UPDATE project_statuses SET name = COALESCE($1, name), color = COALESCE($2, color), sort_order = COALESCE($3, sort_order), hidden = COALESCE($4, hidden) WHERE id = $5 RETURNING id AS "id!: Uuid", project_id AS "project_id!: Uuid", name AS "name!", color AS "color!", sort_order AS "sort_order!", hidden AS "hidden!", created_at AS "created_at!: DateTime" "#, item.changes.name, item.changes.color, item.changes.sort_order, item.changes.hidden, item.id ) .fetch_one(&mut *tx) .await .map_err(|error| { tracing::error!(?error, status_id = %item.id, "failed to update project status"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to update project status", ) })?; results.push(updated); } let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(BulkUpdateProjectStatusesResponse { data: results, txid, })) } ================================================ FILE: crates/remote/src/routes/projects.rs ================================================ use api_types::{ BulkUpdateProjectsRequest, BulkUpdateProjectsResponse, CreateProjectRequest, DeleteResponse, ListProjectsQuery, ListProjectsResponse, MutationResponse, Project, UpdateProjectRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, routing::post, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_member_access, }; use crate::{ AppState, auth::RequestContext, db::{get_txid, projects::ProjectRepository, types::is_valid_hsl_color}, mutation_definition::MutationBuilder, }; /// Mutation definition for Projects - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("projects") .list(list_projects) .get(get_project) .create(create_project) .update(update_project) .delete(delete_project) } pub fn router() -> axum::Router { mutation() .router() .route("/projects/bulk", post(bulk_update_projects)) } #[instrument( name = "projects.list_projects", skip(state, ctx), fields(organization_id = %query.organization_id, user_id = %ctx.user.id) )] async fn list_projects( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?; let projects = ProjectRepository::list_by_organization(state.pool(), query.organization_id) .await .map_err(|error| { tracing::error!(?error, organization_id = %query.organization_id, "failed to list projects"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list projects") })?; Ok(Json(ListProjectsResponse { projects })) } #[instrument( name = "projects.get_project", skip(state, ctx), fields(project_id = %project_id, user_id = %ctx.user.id) )] async fn get_project( State(state): State, Extension(ctx): Extension, Path(project_id): Path, ) -> Result, ErrorResponse> { let project = ProjectRepository::find_by_id(state.pool(), project_id) .await .map_err(|error| { tracing::error!(?error, %project_id, "failed to load project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; Ok(Json(project)) } #[instrument( name = "projects.create_project", skip(state, ctx, payload), fields(organization_id = %payload.organization_id, user_id = %ctx.user.id) )] async fn create_project( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_member_access(state.pool(), payload.organization_id, ctx.user.id).await?; if !is_valid_hsl_color(&payload.color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let response = ProjectRepository::create_with_defaults( state.pool(), payload.id, payload.organization_id, payload.name, payload.color, ) .await .map_err(|error| { tracing::error!(?error, "failed to create project"); db_error(error, "failed to create project") })?; if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "project_created", serde_json::json!({ "project_id": response.data.id, "organization_id": response.data.organization_id, }), ); } Ok(Json(response)) } #[instrument( name = "projects.update_project", skip(state, ctx, payload), fields(project_id = %project_id, user_id = %ctx.user.id) )] async fn update_project( State(state): State, Extension(ctx): Extension, Path(project_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let existing = ProjectRepository::find_by_id(state.pool(), project_id) .await .map_err(|error| { tracing::error!(?error, %project_id, "failed to load project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; ensure_member_access(state.pool(), existing.organization_id, ctx.user.id).await?; if let Some(ref color) = payload.color && !is_valid_hsl_color(color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let response = ProjectRepository::update( state.pool(), project_id, payload.name, payload.color, payload.sort_order, ) .await .map_err(|error| { tracing::error!(?error, "failed to update project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } #[instrument( name = "projects.bulk_update", skip(state, ctx, payload), fields(user_id = %ctx.user.id, count = payload.updates.len()) )] async fn bulk_update_projects( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { if payload.updates.is_empty() { return Ok(Json(BulkUpdateProjectsResponse { data: vec![], txid: 0, })); } let first_project = ProjectRepository::find_by_id(state.pool(), payload.updates[0].id) .await .map_err(|error| { tracing::error!(?error, "failed to find first project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find project") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; let organization_id = first_project.organization_id; ensure_member_access(state.pool(), organization_id, ctx.user.id).await?; let mut tx = crate::db::begin_tx(state.pool()).await.map_err(|error| { tracing::error!(?error, "failed to begin transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let mut results = Vec::with_capacity(payload.updates.len()); for item in payload.updates { let project = ProjectRepository::find_by_id(&mut *tx, item.id) .await .map_err(|error| { tracing::error!(?error, project_id = %item.id, "failed to find project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find project") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; if project.organization_id != organization_id { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "all projects must belong to the same organization", )); } if let Some(ref color) = item.changes.color && !is_valid_hsl_color(color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let updated = ProjectRepository::update_partial( &mut *tx, item.id, item.changes.name, item.changes.color, item.changes.sort_order, ) .await .map_err(|error| { tracing::error!(?error, project_id = %item.id, "failed to update project"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to update project", ) })?; results.push(updated); } let txid = get_txid(&mut *tx).await.map_err(|error| { tracing::error!(?error, "failed to get txid"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; tx.commit().await.map_err(|error| { tracing::error!(?error, "failed to commit transaction"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(BulkUpdateProjectsResponse { data: results, txid, })) } #[instrument( name = "projects.delete_project", skip(state, ctx), fields(project_id = %project_id, user_id = %ctx.user.id) )] async fn delete_project( State(state): State, Extension(ctx): Extension, Path(project_id): Path, ) -> Result, ErrorResponse> { let project = ProjectRepository::find_by_id(state.pool(), project_id) .await .map_err(|error| { tracing::error!(?error, %project_id, "failed to load project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; let response = ProjectRepository::delete(state.pool(), project_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/pull_requests.rs ================================================ use api_types::{ ListPullRequestsQuery, ListPullRequestsResponse, PullRequest, PullRequestStatus, UpsertPullRequestRequest, }; use axum::{ Json, Router, extract::{Extension, Query, State}, http::StatusCode, routing::get, }; use chrono::{DateTime, Utc}; use serde::Deserialize; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_issue_access, }; use crate::{ AppState, auth::RequestContext, db::{ issues::IssueRepository, pull_requests::PullRequestRepository, workspaces::WorkspaceRepository, }, }; #[derive(Debug, Deserialize)] pub struct CreatePullRequestRequest { pub url: String, pub number: i32, pub status: PullRequestStatus, pub merged_at: Option>, pub merge_commit_sha: Option, pub target_branch_name: String, pub issue_id: Uuid, pub local_workspace_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdatePullRequestRequest { pub url: String, pub status: Option, pub merged_at: Option>>, pub merge_commit_sha: Option>, } pub fn router() -> Router { Router::new().route( "/pull_requests", get(list_pull_requests) .post(create_pull_request) .patch(update_pull_request) .put(upsert_pull_request), ) } #[instrument( name = "pull_requests.list_pull_requests", skip(state, ctx), fields(issue_id = %query.issue_id, user_id = %ctx.user.id) )] async fn list_pull_requests( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let pull_requests = PullRequestRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, "failed to list pull requests"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list pull requests", ) })?; Ok(Json(ListPullRequestsResponse { pull_requests })) } #[instrument( name = "pull_requests.create_pull_request", skip(state, ctx, payload), fields(issue_id = %payload.issue_id, local_workspace_id = ?payload.local_workspace_id, user_id = %ctx.user.id) )] async fn create_pull_request( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, payload.issue_id).await?; // Resolve local_workspace_id to remote workspace_id let workspace_id = match payload.local_workspace_id { Some(local_id) => { let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_id) .await .map_err(|error| { tracing::error!(?error, local_workspace_id = %local_id, "failed to find workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace", ) })? .ok_or_else(|| { tracing::warn!(local_workspace_id = %local_id, "workspace not found"); ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found") })?; Some(workspace.id) } None => None, }; let pr = PullRequestRepository::create( state.pool(), payload.url, payload.number, payload.status, payload.merged_at, payload.merge_commit_sha, payload.target_branch_name, payload.issue_id, workspace_id, ) .await .map_err(|error| { tracing::error!(?error, "failed to create pull request"); db_error(error, "failed to create pull request") })?; IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status) .await .map_err(|error| { tracing::error!(?error, "failed to sync issue status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(pr)) } #[instrument( name = "pull_requests.update_pull_request", skip(state, ctx, payload), fields(url = %payload.url, user_id = %ctx.user.id) )] async fn update_pull_request( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { let pull_request = PullRequestRepository::find_by_url(state.pool(), &payload.url) .await .map_err(|error| { tracing::error!(?error, url = %payload.url, "failed to load pull request"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to load pull request", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "pull request not found"))?; ensure_issue_access(state.pool(), ctx.user.id, pull_request.issue_id).await?; let pr = PullRequestRepository::update( state.pool(), pull_request.id, payload.status, payload.merged_at, payload.merge_commit_sha, ) .await .map_err(|error| { tracing::error!(?error, "failed to update pull request"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status) .await .map_err(|error| { tracing::error!(?error, "failed to sync issue status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(pr)) } #[instrument( name = "pull_requests.upsert_pull_request", skip(state, ctx, payload), fields(url = %payload.url, local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id) )] async fn upsert_pull_request( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { // Resolve local_workspace_id to workspace and get issue_id let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id) .await .map_err(|error| { tracing::error!(?error, local_workspace_id = %payload.local_workspace_id, "failed to find workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace", ) })? .ok_or_else(|| { tracing::info!(local_workspace_id = %payload.local_workspace_id, "workspace not found"); ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found") })?; let issue_id = workspace .issue_id .ok_or_else(|| ErrorResponse::new(StatusCode::BAD_REQUEST, "workspace has no issue"))?; ensure_issue_access(state.pool(), ctx.user.id, issue_id).await?; // Try to find existing PR by URL let existing_pr = PullRequestRepository::find_by_url(state.pool(), &payload.url) .await .map_err(|error| { tracing::error!(?error, url = %payload.url, "failed to check for existing PR"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; let pr = if let Some(existing) = existing_pr { if existing.issue_id != issue_id { return Err(ErrorResponse::new( StatusCode::FORBIDDEN, "PR URL belongs to a different issue", )); } PullRequestRepository::update( state.pool(), existing.id, Some(payload.status), Some(payload.merged_at), Some(payload.merge_commit_sha), ) .await .map_err(|error| { tracing::error!(?error, "failed to update pull request"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })? } else { // Create new PR PullRequestRepository::create( state.pool(), payload.url, payload.number, payload.status, payload.merged_at, payload.merge_commit_sha, payload.target_branch_name, issue_id, Some(workspace.id), ) .await .map_err(|error| { tracing::error!(?error, "failed to create pull request"); db_error(error, "failed to create pull request") })? }; IssueRepository::sync_status_from_pull_request(state.pool(), pr.issue_id, pr.status) .await .map_err(|error| { tracing::error!(?error, "failed to sync issue status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(pr)) } ================================================ FILE: crates/remote/src/routes/review.rs ================================================ use std::net::IpAddr; use axum::{ Json, Router, body::Body, extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ AppState, db::reviews::{CreateReviewParams, ReviewRepository}, r2::R2Error, }; pub fn public_router() -> Router { Router::new() .route("/review/init", post(init_review_upload)) .route("/review/start", post(start_review)) .route("/review/{id}/status", get(get_review_status)) .route("/review/{id}", get(get_review)) .route("/review/{id}/metadata", get(get_review_metadata)) .route("/review/{id}/file/{file_hash}", get(get_review_file)) .route("/review/{id}/diff", get(get_review_diff)) .route("/review/{id}/success", post(review_success)) .route("/review/{id}/failed", post(review_failed)) } #[derive(Debug, Deserialize)] pub struct InitReviewRequest { pub gh_pr_url: String, pub email: String, pub pr_title: String, #[serde(default)] pub claude_code_session_id: Option, #[serde(default)] pub content_type: Option, } #[derive(Debug, Serialize)] pub struct InitReviewResponse { pub review_id: Uuid, pub upload_url: String, pub object_key: String, pub expires_at: DateTime, } #[derive(Debug, Serialize)] pub struct ReviewMetadataResponse { pub gh_pr_url: String, pub pr_title: String, } #[derive(Debug, thiserror::Error)] pub enum ReviewError { #[error("Review feature is disabled")] Disabled, #[error("R2 storage not configured")] NotConfigured, #[error("failed to generate upload URL: {0}")] R2Error(#[from] R2Error), #[error("rate limit exceeded")] RateLimited, #[error("unable to determine client IP")] MissingClientIp, #[error("database error: {0}")] Database(#[from] crate::db::reviews::ReviewError), #[error("review worker not configured")] WorkerNotConfigured, #[error("review worker request failed: {0}")] WorkerError(#[from] reqwest::Error), #[error("invalid review ID")] InvalidReviewId, } impl IntoResponse for ReviewError { fn into_response(self) -> Response { let (status, message) = match &self { ReviewError::Disabled => ( StatusCode::SERVICE_UNAVAILABLE, "Review feature is disabled", ), ReviewError::NotConfigured => ( StatusCode::SERVICE_UNAVAILABLE, "Review upload service not available", ), ReviewError::R2Error(e) => { tracing::error!(error = %e, "R2 presign failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate upload URL", ) } ReviewError::RateLimited => ( StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Try again later.", ), ReviewError::MissingClientIp => { (StatusCode::BAD_REQUEST, "Unable to determine client IP") } ReviewError::Database(crate::db::reviews::ReviewError::NotFound) => { (StatusCode::NOT_FOUND, "Review not found") } ReviewError::Database(e) => { tracing::error!(error = %e, "Database error in review"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") } ReviewError::WorkerNotConfigured => ( StatusCode::SERVICE_UNAVAILABLE, "Review worker service not available", ), ReviewError::WorkerError(e) => { tracing::error!(error = %e, "Review worker request failed"); ( StatusCode::BAD_GATEWAY, "Failed to fetch review from worker", ) } ReviewError::InvalidReviewId => (StatusCode::BAD_REQUEST, "Invalid review ID"), }; let body = serde_json::json!({ "error": message }); (status, Json(body)).into_response() } } /// Ensures the GitHub URL has the https:// protocol prefix fn normalize_github_url(url: &str) -> String { let url = url.trim(); if url.starts_with("https://") || url.starts_with("http://") { url.to_string() } else { format!("https://{}", url) } } /// Extract client IP from headers, with fallbacks for local development fn extract_client_ip(headers: &HeaderMap) -> Option { // Try Cloudflare header first (production) if let Some(ip) = headers .get("CF-Connecting-IP") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) { return Some(ip); } // Fallback to X-Forwarded-For (common proxy header) if let Some(ip) = headers .get("X-Forwarded-For") .and_then(|v| v.to_str().ok()) .and_then(|s| s.split(',').next()) // Take first IP in chain .and_then(|s| s.trim().parse().ok()) { return Some(ip); } // Fallback to X-Real-IP if let Some(ip) = headers .get("X-Real-IP") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) { return Some(ip); } // For local development, use localhost Some(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) } /// Check rate limits for the given IP address. /// Limits: 2 reviews per minute, 20 reviews per hour. async fn check_rate_limit(repo: &ReviewRepository<'_>, ip: IpAddr) -> Result<(), ReviewError> { let now = Utc::now(); // Check minute limit (2 per minute) let minute_ago = now - Duration::minutes(1); let minute_count = repo.count_since(ip, minute_ago).await?; if minute_count >= 2 { return Err(ReviewError::RateLimited); } // Check hour limit (20 per hour) let hour_ago = now - Duration::hours(1); let hour_count = repo.count_since(ip, hour_ago).await?; if hour_count >= 20 { return Err(ReviewError::RateLimited); } Ok(()) } pub async fn init_review_upload( State(state): State, headers: HeaderMap, Json(payload): Json, ) -> Result, ReviewError> { if state.config.review_disabled { return Err(ReviewError::Disabled); } // 1. Generate the review ID upfront (used in both R2 path and DB record) let review_id = Uuid::new_v4(); // 2. Extract IP (required for rate limiting) let ip = extract_client_ip(&headers).ok_or(ReviewError::MissingClientIp)?; // 3. Check rate limits let repo = ReviewRepository::new(state.pool()); check_rate_limit(&repo, ip).await?; // 4. Get R2 service let r2 = state.r2().ok_or(ReviewError::NotConfigured)?; // 5. Generate presigned URL with review ID in path let content_type = payload.content_type.as_deref(); let upload = r2.create_presigned_upload(review_id, content_type).await?; // 6. Normalize the GitHub PR URL to ensure it has https:// prefix let normalized_url = normalize_github_url(&payload.gh_pr_url); // 7. Insert DB record with the same review ID, storing folder path let review = repo .create(CreateReviewParams { id: review_id, gh_pr_url: &normalized_url, claude_code_session_id: payload.claude_code_session_id.as_deref(), ip_address: ip, r2_path: &upload.folder_path, email: &payload.email, pr_title: &payload.pr_title, }) .await?; // 8. Return response with review_id Ok(Json(InitReviewResponse { review_id: review.id, upload_url: upload.upload_url, object_key: upload.object_key, expires_at: upload.expires_at, })) } /// Proxy a request to the review worker and return the response. async fn proxy_to_worker(state: &AppState, path: &str) -> Result { let base_url = state .config .review_worker_base_url .as_ref() .ok_or(ReviewError::WorkerNotConfigured)?; let url = format!("{}{}", base_url.trim_end_matches('/'), path); let response = state.http_client.get(&url).send().await?; let status = response.status(); let headers = response.headers().clone(); let bytes = response.bytes().await?; let mut builder = Response::builder().status(status); // Copy relevant headers from the worker response if let Some(content_type) = headers.get("content-type") { builder = builder.header("content-type", content_type); } Ok(builder.body(Body::from(bytes)).unwrap()) } /// Proxy a POST request with JSON body to the review worker async fn proxy_post_to_worker( state: &AppState, path: &str, body: serde_json::Value, ) -> Result { let base_url = state .config .review_worker_base_url .as_ref() .ok_or(ReviewError::WorkerNotConfigured)?; let url = format!("{}{}", base_url.trim_end_matches('/'), path); let response = state.http_client.post(&url).json(&body).send().await?; let status = response.status(); let headers = response.headers().clone(); let bytes = response.bytes().await?; let mut builder = Response::builder().status(status); if let Some(content_type) = headers.get("content-type") { builder = builder.header("content-type", content_type); } Ok(builder.body(Body::from(bytes)).unwrap()) } /// POST /review/start - Start review processing on worker pub async fn start_review( State(state): State, Json(body): Json, ) -> Result { if state.config.review_disabled { return Err(ReviewError::Disabled); } proxy_post_to_worker(&state, "/review/start", body).await } /// GET /review/:id/status - Get review status from worker pub async fn get_review_status( State(state): State, Path(id): Path, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Verify review exists in our database let repo = ReviewRepository::new(state.pool()); let _review = repo.get_by_id(review_id).await?; // Proxy to worker proxy_to_worker(&state, &format!("/review/{}/status", review_id)).await } /// GET /review/:id/metadata - Get PR metadata from database pub async fn get_review_metadata( State(state): State, Path(id): Path, ) -> Result, ReviewError> { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; let repo = ReviewRepository::new(state.pool()); let review = repo.get_by_id(review_id).await?; Ok(Json(ReviewMetadataResponse { gh_pr_url: review.gh_pr_url, pr_title: review.pr_title, })) } /// GET /review/:id - Get complete review result from worker pub async fn get_review( State(state): State, Path(id): Path, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Verify review exists in our database let repo = ReviewRepository::new(state.pool()); let _review = repo.get_by_id(review_id).await?; // Proxy to worker proxy_to_worker(&state, &format!("/review/{}", review_id)).await } /// GET /review/:id/file/:file_hash - Get file content from worker pub async fn get_review_file( State(state): State, Path((id, file_hash)): Path<(String, String)>, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Verify review exists in our database let repo = ReviewRepository::new(state.pool()); let _review = repo.get_by_id(review_id).await?; // Proxy to worker proxy_to_worker(&state, &format!("/review/{}/file/{}", review_id, file_hash)).await } /// GET /review/:id/diff - Get diff for review from worker pub async fn get_review_diff( State(state): State, Path(id): Path, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Verify review exists in our database let repo = ReviewRepository::new(state.pool()); let _review = repo.get_by_id(review_id).await?; // Proxy to worker proxy_to_worker(&state, &format!("/review/{}/diff", review_id)).await } /// POST /review/:id/success - Called by worker when review completes successfully /// Sends success notification email to the user, or posts PR comment for webhook reviews pub async fn review_success( State(state): State, Path(id): Path, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Fetch review from database to get email and PR title let repo = ReviewRepository::new(state.pool()); let review = repo.get_by_id(review_id).await?; // Mark review as completed repo.mark_completed(review_id).await?; // Build review URL let review_url = format!("{}/review/{}", state.server_public_base_url, review_id); // Check if this is a webhook-triggered review if review.is_webhook_review() { // Post PR comment instead of sending email if let Some(github_app) = state.github_app() { let comment = format!( "## Review Complete\n\n\ Your review story is ready!\n\n\ **[View Story]({})**\n\n\ Comment **!reviewfast** on this PR to re-generate the story.", review_url ); let installation_id = review.github_installation_id.unwrap_or(0); let pr_owner = review.pr_owner.as_deref().unwrap_or(""); let pr_repo = review.pr_repo.as_deref().unwrap_or(""); let pr_number = review.pr_number.unwrap_or(0) as u64; if let Err(e) = github_app .post_pr_comment(installation_id, pr_owner, pr_repo, pr_number, &comment) .await { tracing::error!( ?e, review_id = %review_id, "Failed to post success comment to PR" ); } } } else if let Some(email) = &review.email { // CLI review - send email notification state .mailer .send_review_ready(email, &review_url, &review.pr_title) .await; } Ok(StatusCode::OK) } /// POST /review/:id/failed - Called by worker when review fails /// Sends failure notification email to the user, or posts PR comment for webhook reviews pub async fn review_failed( State(state): State, Path(id): Path, ) -> Result { let review_id: Uuid = id.parse().map_err(|_| ReviewError::InvalidReviewId)?; // Fetch review from database to get email and PR title let repo = ReviewRepository::new(state.pool()); let review = repo.get_by_id(review_id).await?; // Mark review as failed repo.mark_failed(review_id).await?; // Check if this is a webhook-triggered review if review.is_webhook_review() { // Post PR comment instead of sending email if let Some(github_app) = state.github_app() { let comment = format!( "## Vibe Kanban Review Failed\n\n\ Unfortunately, the code review could not be completed.\n\n\ Review ID: `{}`", review_id ); let installation_id = review.github_installation_id.unwrap_or(0); let pr_owner = review.pr_owner.as_deref().unwrap_or(""); let pr_repo = review.pr_repo.as_deref().unwrap_or(""); let pr_number = review.pr_number.unwrap_or(0) as u64; if let Err(e) = github_app .post_pr_comment(installation_id, pr_owner, pr_repo, pr_number, &comment) .await { tracing::error!( ?e, review_id = %review_id, "Failed to post failure comment to PR" ); } } } else if let Some(email) = &review.email { // CLI review - send email notification state .mailer .send_review_failed(email, &review.pr_title, &review_id.to_string()) .await; } Ok(StatusCode::OK) } ================================================ FILE: crates/remote/src/routes/tags.rs ================================================ use api_types::{ CreateTagRequest, DeleteResponse, ListTagsQuery, ListTagsResponse, MutationResponse, Tag, UpdateTagRequest, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_project_access, }; use crate::{ AppState, auth::RequestContext, db::{tags::TagRepository, types::is_valid_hsl_color}, mutation_definition::MutationBuilder, }; /// Mutation definition for Tags - provides both router and TypeScript metadata. pub fn mutation() -> MutationBuilder { MutationBuilder::new("tags") .list(list_tags) .get(get_tag) .create(create_tag) .update(update_tag) .delete(delete_tag) } pub fn router() -> axum::Router { mutation().router() } #[instrument( name = "tags.list_tags", skip(state, ctx), fields(project_id = %query.project_id, user_id = %ctx.user.id) )] async fn list_tags( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let tags = TagRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list tags"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list tags") })?; Ok(Json(ListTagsResponse { tags })) } #[instrument( name = "tags.get_tag", skip(state, ctx), fields(tag_id = %tag_id, user_id = %ctx.user.id) )] async fn get_tag( State(state): State, Extension(ctx): Extension, Path(tag_id): Path, ) -> Result, ErrorResponse> { let tag = TagRepository::find_by_id(state.pool(), tag_id) .await .map_err(|error| { tracing::error!(?error, %tag_id, "failed to load tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load tag") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "tag not found"))?; ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?; Ok(Json(tag)) } #[instrument( name = "tags.create_tag", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id) )] async fn create_tag( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result>, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?; if !is_valid_hsl_color(&payload.color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } let response = TagRepository::create( state.pool(), payload.id, payload.project_id, payload.name, payload.color, ) .await .map_err(|error| { tracing::error!(?error, "failed to create tag"); db_error(error, "failed to create tag") })?; Ok(Json(response)) } #[instrument( name = "tags.update_tag", skip(state, ctx, payload), fields(tag_id = %tag_id, user_id = %ctx.user.id) )] async fn update_tag( State(state): State, Extension(ctx): Extension, Path(tag_id): Path, Json(payload): Json, ) -> Result>, ErrorResponse> { let tag = TagRepository::find_by_id(state.pool(), tag_id) .await .map_err(|error| { tracing::error!(?error, %tag_id, "failed to load tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load tag") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "tag not found"))?; ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?; if let Some(ref color) = payload.color && !is_valid_hsl_color(color) { return Err(ErrorResponse::new( StatusCode::BAD_REQUEST, "Invalid color format. Expected HSL format: 'H S% L%'", )); } // Partial update - use existing values if not provided let response = TagRepository::update(state.pool(), tag_id, payload.name, payload.color) .await .map_err(|error| { tracing::error!(?error, "failed to update tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } #[instrument( name = "tags.delete_tag", skip(state, ctx), fields(tag_id = %tag_id, user_id = %ctx.user.id) )] async fn delete_tag( State(state): State, Extension(ctx): Extension, Path(tag_id): Path, ) -> Result, ErrorResponse> { let tag = TagRepository::find_by_id(state.pool(), tag_id) .await .map_err(|error| { tracing::error!(?error, %tag_id, "failed to load tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load tag") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "tag not found"))?; ensure_project_access(state.pool(), ctx.user.id, tag.project_id).await?; let response = TagRepository::delete(state.pool(), tag_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete tag"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(Json(response)) } ================================================ FILE: crates/remote/src/routes/tokens.rs ================================================ use api_types::{TokenRefreshRequest, TokenRefreshResponse}; use axum::{ Json, Router, extract::State, http::StatusCode, response::{IntoResponse, Response}, routing::post, }; use tracing::{info, warn}; use crate::{ AppState, audit::{self, AuditAction, AuditEvent}, auth::{JwtError, OAuthTokenValidationError}, db::{ auth::{AuthSessionError, AuthSessionRepository}, identity_errors::IdentityError, oauth_accounts::{OAuthAccountError, OAuthAccountRepository}, users::UserRepository, }, }; pub fn public_router() -> Router { Router::new().route("/tokens/refresh", post(refresh_token)) } #[derive(Debug, thiserror::Error)] pub enum TokenRefreshError { #[error("invalid refresh token")] InvalidToken, #[error("session has been revoked")] SessionRevoked, #[error("refresh token expired")] TokenExpired, #[error("refresh token reused - possible token theft")] TokenReuseDetected, #[error("provider token has been revoked")] ProviderTokenRevoked, #[error("temporary failure validating provider token")] ProviderValidationUnavailable(String), #[error(transparent)] Jwt(#[from] JwtError), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] SessionError(#[from] AuthSessionError), #[error(transparent)] Identity(#[from] IdentityError), } impl From for TokenRefreshError { fn from(err: OAuthTokenValidationError) -> Self { match err { OAuthTokenValidationError::ProviderAccountNotLinked | OAuthTokenValidationError::ProviderTokenValidationFailed => { TokenRefreshError::ProviderTokenRevoked } OAuthTokenValidationError::FetchAccountsFailed(inner) => match inner { OAuthAccountError::Database(db_err) => TokenRefreshError::Database(db_err), }, OAuthTokenValidationError::ValidationUnavailable(reason) => { TokenRefreshError::ProviderValidationUnavailable(reason) } } } } impl From for TokenRefreshError { fn from(err: OAuthAccountError) -> Self { match err { OAuthAccountError::Database(db_err) => TokenRefreshError::Database(db_err), } } } pub async fn refresh_token( State(state): State, Json(payload): Json, ) -> Result { let jwt_service = &state.jwt(); let session_repo = AuthSessionRepository::new(state.pool()); let token_details = match jwt_service.decode_refresh_token(&payload.refresh_token) { Ok(details) => details, Err(JwtError::TokenExpired) => return Err(TokenRefreshError::TokenExpired), Err(_) => return Err(TokenRefreshError::InvalidToken), }; let session = match session_repo.get(token_details.session_id).await { Ok(session) => session, Err(AuthSessionError::NotFound) => return Err(TokenRefreshError::SessionRevoked), Err(error) => return Err(TokenRefreshError::SessionError(error)), }; if session.revoked_at.is_some() { return Err(TokenRefreshError::SessionRevoked); } if session.refresh_token_id != Some(token_details.refresh_token_id) || session_repo .is_refresh_token_revoked(token_details.refresh_token_id) .await? { // Token was reused, revoke all user sessions as a security measure let revoked_count = session_repo .revoke_all_user_sessions(token_details.user_id) .await?; warn!( user_id = %token_details.user_id, session_id = %token_details.session_id, revoked_sessions = revoked_count, "Refresh token reuse detected. Revoked all user sessions." ); audit::emit( AuditEvent::system(AuditAction::AuthTokenReuseDetected) .user(token_details.user_id, Some(token_details.session_id)) .resource("auth_session", Some(token_details.session_id)) .http("POST", "/v1/tokens/refresh", 401) .description(format!("{revoked_count} sessions revoked")), ); return Err(TokenRefreshError::TokenReuseDetected); } // Move encrypted_provider_tokens from legacy refresh token claim to the DB if let Some(legacy_provider_token_details) = token_details.legacy_provider_token_details.as_ref() && let oauth_account_repo = OAuthAccountRepository::new(state.pool()) && oauth_account_repo .get_by_user_provider(token_details.user_id, &token_details.provider) .await? .is_some_and(|account| account.encrypted_provider_tokens.is_none()) { let encrypted_provider_tokens = jwt_service.encrypt_provider_tokens(legacy_provider_token_details)?; oauth_account_repo .update_encrypted_provider_tokens( token_details.user_id, &token_details.provider, &encrypted_provider_tokens, ) .await?; info!( user_id = %token_details.user_id, provider = %token_details.provider, session_id = %token_details.session_id, "Backfilled DB provider token from legacy refresh token claim" ); } state .oauth_token_validator() .validate( &token_details.provider, token_details.user_id, token_details.session_id, ) .await?; let user_repo = UserRepository::new(state.pool()); let user = user_repo.fetch_user(token_details.user_id).await?; let tokens = jwt_service.generate_tokens(&session, &user, &token_details.provider)?; let old_token_id = token_details.refresh_token_id; let new_token_id = tokens.refresh_token_id; match session_repo .rotate_tokens(session.id, old_token_id, new_token_id) .await { Ok(_) => {} Err(AuthSessionError::TokenReuseDetected) => { let revoked_count = session_repo .revoke_all_user_sessions(token_details.user_id) .await?; warn!( user_id = %token_details.user_id, session_id = %token_details.session_id, revoked_sessions = revoked_count, "Detected concurrent refresh attempt; revoked all user sessions" ); audit::emit( AuditEvent::system(AuditAction::AuthTokenReuseDetected) .user(token_details.user_id, Some(token_details.session_id)) .resource("auth_session", Some(token_details.session_id)) .http("POST", "/v1/tokens/refresh", 401) .description(format!( "{revoked_count} sessions revoked (concurrent reuse)" )), ); return Err(TokenRefreshError::TokenReuseDetected); } Err(error) => return Err(TokenRefreshError::SessionError(error)), } audit::emit( AuditEvent::system(AuditAction::AuthTokenRefresh) .user(token_details.user_id, Some(token_details.session_id)) .resource("auth_session", Some(token_details.session_id)) .http("POST", "/v1/tokens/refresh", 200), ); Ok(Json(TokenRefreshResponse { access_token: tokens.access_token, refresh_token: tokens.refresh_token, }) .into_response()) } impl IntoResponse for TokenRefreshError { fn into_response(self) -> Response { let (status, error_code) = match self { TokenRefreshError::InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"), TokenRefreshError::TokenExpired => (StatusCode::UNAUTHORIZED, "token_expired"), TokenRefreshError::SessionRevoked => (StatusCode::UNAUTHORIZED, "session_revoked"), TokenRefreshError::TokenReuseDetected => { (StatusCode::UNAUTHORIZED, "token_reuse_detected") } TokenRefreshError::ProviderTokenRevoked => { (StatusCode::UNAUTHORIZED, "provider_token_revoked") } TokenRefreshError::ProviderValidationUnavailable(ref reason) => { warn!( reason = reason.as_str(), "Provider validation temporarily unavailable during refresh" ); ( StatusCode::SERVICE_UNAVAILABLE, "provider_validation_unavailable", ) } TokenRefreshError::Jwt(_) => (StatusCode::UNAUTHORIZED, "invalid_token"), TokenRefreshError::Identity(_) => (StatusCode::UNAUTHORIZED, "identity_error"), TokenRefreshError::Database(ref err) => { tracing::error!(error = %err, "Database error during token refresh"); (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") } TokenRefreshError::SessionError(ref err) => { tracing::error!(error = %err, "Session error during token refresh"); (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") } }; let body = serde_json::json!({ "error": error_code, "message": self.to_string() }); (status, Json(body)).into_response() } } ================================================ FILE: crates/remote/src/routes/workspaces.rs ================================================ use api_types::{DeleteWorkspaceRequest, UpdateWorkspaceRequest, Workspace}; use axum::{ Json, Router, extract::{Extension, Path, State}, http::StatusCode, routing::{delete, get, head, post}, }; use serde::Deserialize; use tracing::instrument; use uuid::Uuid; use super::{ error::{ErrorResponse, db_error}, organization_members::ensure_project_access, }; use crate::{ AppState, auth::RequestContext, db::{ issues::IssueRepository, workspaces::{CreateWorkspaceParams, WorkspaceRepository}, }, }; #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { pub project_id: Uuid, pub local_workspace_id: Option, pub issue_id: Option, pub name: Option, pub archived: Option, pub files_changed: Option, pub lines_added: Option, pub lines_removed: Option, } pub fn router() -> Router { Router::new() .route( "/workspaces", post(create_workspace) .patch(update_workspace) .delete(delete_workspace), ) .route("/workspaces/{workspace_id}", delete(unlink_workspace)) .route( "/workspaces/{local_workspace_id}/sync_issue_status_from_local_merge", post(sync_issue_status_from_local_merge), ) .route( "/workspaces/by-local-id/{local_workspace_id}", get(get_workspace_by_local_id), ) .route( "/workspaces/exists/{local_workspace_id}", head(workspace_exists), ) } #[instrument( name = "workspaces.create_workspace", skip(state, ctx, payload), fields(project_id = %payload.project_id, user_id = %ctx.user.id) )] async fn create_workspace( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, payload.project_id).await?; let workspace = WorkspaceRepository::create( state.pool(), CreateWorkspaceParams { project_id: payload.project_id, owner_user_id: ctx.user.id, local_workspace_id: payload.local_workspace_id, issue_id: payload.issue_id, name: payload.name, archived: payload.archived, files_changed: payload.files_changed, lines_added: payload.lines_added, lines_removed: payload.lines_removed, }, ) .await .map_err(|error| { tracing::error!(?error, "failed to create workspace"); db_error(error, "failed to create workspace") })?; if let Some(issue_id) = payload.issue_id { if let Err(error) = IssueRepository::sync_issue_from_workspace_created(state.pool(), issue_id, ctx.user.id) .await { tracing::warn!(?error, "failed to sync issue from workspace creation"); } if let Some(analytics) = state.analytics() { analytics.track( ctx.user.id, "workspace_created_from_issue", serde_json::json!({ "workspace_id": workspace.id, "project_id": workspace.project_id, "issue_id": issue_id, }), ); } } Ok(Json(workspace)) } #[instrument( name = "workspaces.update_workspace", skip(state, ctx, payload), fields(local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id) )] async fn update_workspace( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result, ErrorResponse> { let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id) .await .map_err(|error| { tracing::error!(?error, local_workspace_id = %payload.local_workspace_id, "failed to find workspace"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found"))?; ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?; let updated = WorkspaceRepository::update( state.pool(), workspace.id, payload.name, payload.archived, payload.files_changed, payload.lines_added, payload.lines_removed, ) .await .map_err(|error| { tracing::error!(?error, "failed to update workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to update workspace", ) })?; Ok(Json(updated)) } #[instrument( name = "workspaces.sync_issue_status_from_local_merge", skip(state, ctx), fields(local_workspace_id = %local_workspace_id, user_id = %ctx.user.id) )] async fn sync_issue_status_from_local_merge( State(state): State, Extension(ctx): Extension, Path(local_workspace_id): Path, ) -> Result { let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_workspace_id) .await .map_err(|error| { tracing::error!(?error, local_workspace_id = %local_workspace_id, "failed to find workspace"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace") })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found"))?; ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?; let Some(issue_id) = workspace.issue_id else { return Ok(StatusCode::NO_CONTENT); }; IssueRepository::sync_status_from_local_workspace_merge(state.pool(), issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %issue_id, "failed to sync issue status"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; Ok(StatusCode::NO_CONTENT) } #[instrument( name = "workspaces.delete_workspace", skip(state, ctx, payload), fields(local_workspace_id = %payload.local_workspace_id, user_id = %ctx.user.id) )] async fn delete_workspace( State(state): State, Extension(ctx): Extension, Json(payload): Json, ) -> Result { let workspace = WorkspaceRepository::find_by_local_id(state.pool(), payload.local_workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to find workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found"))?; ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?; WorkspaceRepository::delete_by_local_id(state.pool(), payload.local_workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to delete workspace", ) })?; Ok(StatusCode::NO_CONTENT) } #[instrument( name = "workspaces.unlink_workspace", skip(state, ctx), fields(workspace_id = %workspace_id, user_id = %ctx.user.id) )] async fn unlink_workspace( State(state): State, Extension(ctx): Extension, Path(workspace_id): Path, ) -> Result { let workspace = WorkspaceRepository::find_by_id(state.pool(), workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to find workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found"))?; ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?; WorkspaceRepository::delete(state.pool(), workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to delete workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to delete workspace", ) })?; Ok(StatusCode::NO_CONTENT) } #[instrument( name = "workspaces.get_workspace_by_local_id", skip(state, ctx), fields(local_workspace_id = %local_workspace_id, user_id = %ctx.user.id) )] async fn get_workspace_by_local_id( State(state): State, Extension(ctx): Extension, Path(local_workspace_id): Path, ) -> Result, ErrorResponse> { let workspace = WorkspaceRepository::find_by_local_id(state.pool(), local_workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to find workspace"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to find workspace", ) })? .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "workspace not found"))?; ensure_project_access(state.pool(), ctx.user.id, workspace.project_id).await?; Ok(Json(workspace)) } #[instrument( name = "workspaces.workspace_exists", skip(state, _ctx), fields(local_workspace_id = %local_workspace_id) )] async fn workspace_exists( State(state): State, Extension(_ctx): Extension, Path(local_workspace_id): Path, ) -> Result { let exists = WorkspaceRepository::exists_by_local_id(state.pool(), local_workspace_id) .await .map_err(|error| { tracing::error!(?error, "failed to check workspace existence"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to check workspace", ) })?; if exists { Ok(StatusCode::OK) } else { Err(ErrorResponse::new( StatusCode::NOT_FOUND, "workspace not found", )) } } ================================================ FILE: crates/remote/src/shape_definition.rs ================================================ //! Shape infrastructure: struct, trait, and macro. use std::marker::PhantomData; use ts_rs::TS; #[derive(Debug)] pub struct ShapeDefinition { pub name: &'static str, pub table: &'static str, pub where_clause: &'static str, pub params: &'static [&'static str], pub url: &'static str, pub _phantom: PhantomData, } /// Trait to allow heterogeneous collection of shapes for export. /// /// This enables collecting `ShapeDefinition` values with different `T` /// into a single `Vec<&dyn ShapeExport>`. pub trait ShapeExport: Sync { fn name(&self) -> &'static str; fn table(&self) -> &'static str; fn where_clause(&self) -> &'static str; fn params(&self) -> &'static [&'static str]; fn url(&self) -> &'static str; fn ts_type_name(&self) -> String; } impl ShapeExport for ShapeDefinition { fn name(&self) -> &'static str { self.name } fn table(&self) -> &'static str { self.table } fn where_clause(&self) -> &'static str { self.where_clause } fn params(&self) -> &'static [&'static str] { self.params } fn url(&self) -> &'static str { self.url } fn ts_type_name(&self) -> String { T::name() } } /// Macro to construct a `ShapeDefinition` with compile-time SQL validation. /// /// Usage: /// ```ignore /// pub const PROJECTS_SHAPE: ShapeDefinition = define_shape!( /// table: "projects", /// where_clause: r#""organization_id" = $1"#, /// url: "/shape/projects", /// params: ["organization_id"] /// ); /// ``` #[macro_export] macro_rules! define_shape { ( name: $name:literal, table: $table:literal, where_clause: $where:literal, url: $url:expr, params: [$($param:literal),* $(,)?] $(,)? ) => {{ #[allow(dead_code)] fn _validate() { let _ = sqlx::query!( "SELECT 1 AS v FROM " + $table + " WHERE " + $where $(, { let _ = stringify!($param); uuid::Uuid::nil() })* ); } $crate::shape_definition::ShapeDefinition { name: $name, table: $table, where_clause: $where, params: &[$($param),*], url: $url, _phantom: std::marker::PhantomData, } }}; } ================================================ FILE: crates/remote/src/shape_route.rs ================================================ //! Unified registration for Electric proxy + REST fallback routes. //! //! Each shape has exactly one proxy handler (GET on its URL) and a required //! REST fallback route. `ShapeRoute::new` pairs the shape with its //! authorization scope and fallback, then registers both routes in one call. //! //! # Example //! //! ```ignore //! use crate::shape_route_builder::{ShapeRoute, ShapeScope}; //! use crate::shapes; //! //! let route = ShapeRoute::new( //! &shapes::PROJECTS_SHAPE, //! ShapeScope::Org, //! "/fallback/projects", //! fallback_list_projects, //! ); //! ``` use axum::{ extract::{Extension, Path, Query, State}, handler::Handler, routing::{MethodRouter, get}, }; use serde::Deserialize; use ts_rs::TS; use uuid::Uuid; use crate::{ AppState, auth::RequestContext, db::organization_members, routes::electric_proxy::{OrgShapeQuery, ProxyError, ShapeQuery, proxy_table}, shape_definition::{ShapeDefinition, ShapeExport}, }; // ============================================================================= // HasQueryParams — structural trait linking handlers to their query extractor // ============================================================================= /// Marker trait implemented for extractor tuples that include `Query`. /// /// This links a fallback handler's query extractor to the declared query type, /// ensuring the handler accepts the correct scope parameters. /// Same pattern as `HasJsonPayload` in `mutation_definition`. pub trait HasQueryParams {} impl HasQueryParams for (Query,) {} impl HasQueryParams for (A, Query) {} impl HasQueryParams for (A, B, Query) {} impl HasQueryParams for (A, B, C, Query) {} impl HasQueryParams for (A, B, C, D, Query) {} // ============================================================================= // Fallback query types — one per scope pattern // ============================================================================= /// Query params for org-scoped fallback handlers (Org, OrgWithUser). #[derive(Debug, Deserialize)] pub struct OrgFallbackQuery { pub organization_id: Uuid, } /// Query params for project-scoped fallback handlers. #[derive(Debug, Deserialize)] pub struct ProjectFallbackQuery { pub project_id: Uuid, } /// Query params for issue-scoped fallback handlers. #[derive(Debug, Deserialize)] pub struct IssueFallbackQuery { pub issue_id: Uuid, } /// Marker for fallback handlers that require no query parameters. /// Used for User-scoped shapes where the user ID comes from auth context. /// Analogous to `NoCreate` in `MutationBuilder`. #[derive(Debug, Deserialize)] pub struct NoQueryParams {} // ============================================================================= // ShapeScope — authorization patterns for Electric proxy routes // ============================================================================= /// Authorization scope for an Electric proxy route. /// /// Each variant maps to a distinct combination of extractor types, /// authorization check, and Electric parameter construction. #[derive(Debug, Clone, Copy)] pub enum ShapeScope { /// Org-scoped: `organization_id` from query. /// Auth: `assert_membership(organization_id, user_id)` /// Electric params: `[organization_id]` Org, /// Org-scoped with user injection: `organization_id` from query. /// Auth: `assert_membership(organization_id, user_id)` /// Electric params: `[organization_id, user_id]` OrgWithUser, /// Project-scoped: `{project_id}` from URL path. /// Auth: `assert_project_access(project_id, user_id)` /// Electric params: `[project_id]` Project, /// Issue-scoped: `{issue_id}` from URL path. /// Auth: `assert_issue_access(issue_id, user_id)` /// Electric params: `[issue_id]` Issue, /// User-scoped: no client-provided scope param. /// Auth: none (implicit — user can only see their own data) /// Electric params: `[user_id]` User, } // ============================================================================= // ShapeRoute // ============================================================================= /// A shape route: router, shape metadata, and fallback URL. pub struct ShapeRoute { pub router: axum::Router, /// Type-erased shape metadata (table, params, url, ts_type_name). pub shape: &'static dyn ShapeExport, /// REST fallback URL, e.g. `"/fallback/projects"`. pub fallback_url: &'static str, } impl ShapeRoute { /// Create a shape route: Electric proxy handler + REST fallback, type-erased. /// /// The fallback handler's extractor tuple must include `Query` (enforced by /// `HasQueryParams`), ensuring the handler accepts the correct scope /// parameters. Use `Query` for handlers that don't need /// query parameters (e.g. User-scoped shapes). pub fn new( shape: &'static ShapeDefinition, scope: ShapeScope, fallback_url: &'static str, fallback_handler: H, ) -> Self where T: TS + Sync + Send + 'static, H: Handler + Clone + Send + 'static, HT: HasQueryParams + 'static, { let proxy_handler = build_proxy_handler(shape, scope); let router = axum::Router::new() .route(shape.url(), proxy_handler) .route(fallback_url, get(fallback_handler)); Self { router, shape, fallback_url, } } } // ============================================================================= // Handler construction // ============================================================================= /// Build the appropriate GET handler for a shape based on its authorization scope. fn build_proxy_handler( shape: &'static dyn ShapeExport, scope: ShapeScope, ) -> MethodRouter { match scope { ShapeScope::Org => get( move |State(state): State, Extension(ctx): Extension, Query(query): Query| async move { organization_members::assert_membership( state.pool(), query.organization_id, ctx.user.id, ) .await .map_err(|e| ProxyError::Authorization(e.to_string()))?; proxy_table( &state, shape, &query.params, &[query.organization_id.to_string()], ctx.session_id, ) .await }, ), ShapeScope::OrgWithUser => get( move |State(state): State, Extension(ctx): Extension, Query(query): Query| async move { organization_members::assert_membership( state.pool(), query.organization_id, ctx.user.id, ) .await .map_err(|e| ProxyError::Authorization(e.to_string()))?; proxy_table( &state, shape, &query.params, &[query.organization_id.to_string(), ctx.user.id.to_string()], ctx.session_id, ) .await }, ), ShapeScope::Project => get( move |State(state): State, Extension(ctx): Extension, Path(project_id): Path, Query(query): Query| async move { organization_members::assert_project_access(state.pool(), project_id, ctx.user.id) .await .map_err(|e| ProxyError::Authorization(e.to_string()))?; proxy_table( &state, shape, &query.params, &[project_id.to_string()], ctx.session_id, ) .await }, ), ShapeScope::Issue => get( move |State(state): State, Extension(ctx): Extension, Path(issue_id): Path, Query(query): Query| async move { organization_members::assert_issue_access(state.pool(), issue_id, ctx.user.id) .await .map_err(|e| ProxyError::Authorization(e.to_string()))?; proxy_table( &state, shape, &query.params, &[issue_id.to_string()], ctx.session_id, ) .await }, ), ShapeScope::User => get( move |State(state): State, Extension(ctx): Extension, Query(query): Query| async move { proxy_table( &state, shape, &query.params, &[ctx.user.id.to_string()], ctx.session_id, ) .await }, ), } } ================================================ FILE: crates/remote/src/shape_routes.rs ================================================ //! All shape route declarations with authorization scope and REST fallback. use api_types::{ ListIssueAssigneesResponse, ListIssueCommentReactionsResponse, ListIssueCommentsResponse, ListIssueFollowersResponse, ListIssueRelationshipsResponse, ListIssueTagsResponse, ListIssuesResponse, ListProjectStatusesResponse, ListProjectsResponse, ListPullRequestsResponse, ListTagsResponse, Notification, OrganizationMember, SearchIssuesRequest, User, Workspace, }; use axum::{ Json, extract::{Extension, Query, State}, http::StatusCode, }; use serde::Serialize; use crate::{ AppState, auth::RequestContext, db::{ issue_assignees::IssueAssigneeRepository, issue_comment_reactions::IssueCommentReactionRepository, issue_comments::IssueCommentRepository, issue_followers::IssueFollowerRepository, issue_relationships::IssueRelationshipRepository, issue_tags::IssueTagRepository, issues::IssueRepository, notifications::NotificationRepository, organization_members, project_statuses::ProjectStatusRepository, projects::ProjectRepository, pull_requests::PullRequestRepository, tags::TagRepository, workspaces::WorkspaceRepository, }, routes::{ error::ErrorResponse, organization_members::{ensure_issue_access, ensure_member_access, ensure_project_access}, }, shape_route::{ IssueFallbackQuery, NoQueryParams, OrgFallbackQuery, ProjectFallbackQuery, ShapeRoute, ShapeScope, }, shapes, }; // ============================================================================= // Response types not defined in api-types (field name must match shape table) // ============================================================================= #[derive(Debug, Serialize)] struct ListNotificationsResponse { notifications: Vec, } #[derive(Debug, Serialize)] struct ListOrganizationMembersResponse { organization_member_metadata: Vec, } #[derive(Debug, Serialize)] struct ListUsersResponse { users: Vec, } #[derive(Debug, Serialize)] struct ListWorkspacesResponse { workspaces: Vec, } // ============================================================================= // Shape route registration // ============================================================================= /// All shape routes: built and type-erased. /// /// This is the single source of truth for shape registration. pub fn all_shape_routes() -> Vec { vec![ // Organization-scoped ShapeRoute::new( &shapes::PROJECTS_SHAPE, ShapeScope::Org, "/fallback/projects", fallback_list_projects, ), ShapeRoute::new( &shapes::NOTIFICATIONS_SHAPE, ShapeScope::User, "/fallback/notifications", fallback_list_notifications, ), ShapeRoute::new( &shapes::ORGANIZATION_MEMBERS_SHAPE, ShapeScope::Org, "/fallback/organization_members", fallback_list_organization_members, ), ShapeRoute::new( &shapes::USERS_SHAPE, ShapeScope::Org, "/fallback/users", fallback_list_users, ), // Project-scoped ShapeRoute::new( &shapes::PROJECT_TAGS_SHAPE, ShapeScope::Project, "/fallback/tags", fallback_list_tags, ), ShapeRoute::new( &shapes::PROJECT_PROJECT_STATUSES_SHAPE, ShapeScope::Project, "/fallback/project_statuses", fallback_list_project_statuses, ), ShapeRoute::new( &shapes::PROJECT_ISSUES_SHAPE, ShapeScope::Project, "/fallback/issues", fallback_list_issues, ), ShapeRoute::new( &shapes::USER_WORKSPACES_SHAPE, ShapeScope::User, "/fallback/user_workspaces", fallback_list_user_workspaces, ), ShapeRoute::new( &shapes::PROJECT_WORKSPACES_SHAPE, ShapeScope::Project, "/fallback/project_workspaces", fallback_list_project_workspaces, ), // Project-scoped issue-related ShapeRoute::new( &shapes::PROJECT_ISSUE_ASSIGNEES_SHAPE, ShapeScope::Project, "/fallback/issue_assignees", fallback_list_issue_assignees, ), ShapeRoute::new( &shapes::PROJECT_ISSUE_FOLLOWERS_SHAPE, ShapeScope::Project, "/fallback/issue_followers", fallback_list_issue_followers, ), ShapeRoute::new( &shapes::PROJECT_ISSUE_TAGS_SHAPE, ShapeScope::Project, "/fallback/issue_tags", fallback_list_issue_tags, ), ShapeRoute::new( &shapes::PROJECT_ISSUE_RELATIONSHIPS_SHAPE, ShapeScope::Project, "/fallback/issue_relationships", fallback_list_issue_relationships, ), ShapeRoute::new( &shapes::PROJECT_PULL_REQUESTS_SHAPE, ShapeScope::Project, "/fallback/pull_requests", fallback_list_pull_requests, ), // Issue-scoped ShapeRoute::new( &shapes::ISSUE_COMMENTS_SHAPE, ShapeScope::Issue, "/fallback/issue_comments", fallback_list_issue_comments, ), ShapeRoute::new( &shapes::ISSUE_REACTIONS_SHAPE, ShapeScope::Issue, "/fallback/issue_comment_reactions", fallback_list_issue_comment_reactions, ), ] } // ============================================================================= // Org-scoped fallback handlers // ============================================================================= async fn fallback_list_projects( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?; let projects = ProjectRepository::list_by_organization(state.pool(), query.organization_id) .await .map_err(|error| { tracing::error!(?error, organization_id = %query.organization_id, "failed to list projects (fallback)"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list projects") })?; Ok(Json(ListProjectsResponse { projects })) } async fn fallback_list_notifications( State(state): State, Extension(ctx): Extension, Query(_query): Query, ) -> Result, ErrorResponse> { let notifications = NotificationRepository::list_by_user(state.pool(), ctx.user.id, true) .await .map_err(|error| { tracing::error!( ?error, user_id = %ctx.user.id, "failed to list notifications (fallback)" ); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list notifications", ) })?; Ok(Json(ListNotificationsResponse { notifications })) } async fn fallback_list_organization_members( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?; let organization_member_metadata = organization_members::list_by_organization(state.pool(), query.organization_id) .await .map_err(|error| { tracing::error!(?error, organization_id = %query.organization_id, "failed to list organization members (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list organization members", ) })?; Ok(Json(ListOrganizationMembersResponse { organization_member_metadata, })) } async fn fallback_list_users( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_member_access(state.pool(), query.organization_id, ctx.user.id).await?; let users = organization_members::list_users_by_organization(state.pool(), query.organization_id) .await .map_err(|error| { tracing::error!(?error, organization_id = %query.organization_id, "failed to list users (fallback)"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list users") })?; Ok(Json(ListUsersResponse { users })) } // ============================================================================= // Project-scoped fallback handlers // ============================================================================= async fn fallback_list_tags( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let tags = TagRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list tags (fallback)"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list tags") })?; Ok(Json(ListTagsResponse { tags })) } async fn fallback_list_project_statuses( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let project_statuses = ProjectStatusRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list project statuses (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list project statuses", ) })?; Ok(Json(ListProjectStatusesResponse { project_statuses })) } async fn fallback_list_issues( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let response = IssueRepository::search( state.pool(), &SearchIssuesRequest { project_id: query.project_id, status_id: None, status_ids: None, priority: None, parent_issue_id: None, search: None, simple_id: None, assignee_user_id: None, tag_id: None, tag_ids: None, sort_field: None, sort_direction: None, limit: None, offset: None, }, ) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list issues (fallback)"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list issues") })?; Ok(Json(response)) } async fn fallback_list_project_workspaces( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let workspaces = WorkspaceRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list workspaces (fallback)"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list workspaces") })?; Ok(Json(ListWorkspacesResponse { workspaces })) } async fn fallback_list_issue_assignees( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let issue_assignees = IssueAssigneeRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list issue assignees (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue assignees", ) })?; Ok(Json(ListIssueAssigneesResponse { issue_assignees })) } async fn fallback_list_issue_followers( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let issue_followers = IssueFollowerRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list issue followers (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue followers", ) })?; Ok(Json(ListIssueFollowersResponse { issue_followers })) } async fn fallback_list_issue_tags( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let issue_tags = IssueTagRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list issue tags (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue tags", ) })?; Ok(Json(ListIssueTagsResponse { issue_tags })) } async fn fallback_list_issue_relationships( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let issue_relationships = IssueRelationshipRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list issue relationships (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue relationships", ) })?; Ok(Json(ListIssueRelationshipsResponse { issue_relationships, })) } async fn fallback_list_pull_requests( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_project_access(state.pool(), ctx.user.id, query.project_id).await?; let pull_requests = PullRequestRepository::list_by_project(state.pool(), query.project_id) .await .map_err(|error| { tracing::error!(?error, project_id = %query.project_id, "failed to list pull requests (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list pull requests", ) })?; Ok(Json(ListPullRequestsResponse { pull_requests })) } // ============================================================================= // User-scoped fallback handlers // ============================================================================= async fn fallback_list_user_workspaces( State(state): State, Extension(ctx): Extension, Query(_): Query, ) -> Result, ErrorResponse> { let workspaces = WorkspaceRepository::list_by_owner(state.pool(), ctx.user.id) .await .map_err(|error| { tracing::error!(?error, user_id = %ctx.user.id, "failed to list user workspaces (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list workspaces", ) })?; Ok(Json(ListWorkspacesResponse { workspaces })) } // ============================================================================= // Issue-scoped fallback handlers // ============================================================================= async fn fallback_list_issue_comments( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_comments = IssueCommentRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue comments (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue comments", ) })?; Ok(Json(ListIssueCommentsResponse { issue_comments })) } async fn fallback_list_issue_comment_reactions( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> Result, ErrorResponse> { ensure_issue_access(state.pool(), ctx.user.id, query.issue_id).await?; let issue_comment_reactions = IssueCommentReactionRepository::list_by_issue(state.pool(), query.issue_id) .await .map_err(|error| { tracing::error!(?error, issue_id = %query.issue_id, "failed to list issue comment reactions (fallback)"); ErrorResponse::new( StatusCode::INTERNAL_SERVER_ERROR, "failed to list issue comment reactions", ) })?; Ok(Json(ListIssueCommentReactionsResponse { issue_comment_reactions, })) } ================================================ FILE: crates/remote/src/shapes.rs ================================================ //! All shape constant instances for realtime streaming. use api_types::{ Issue, IssueAssignee, IssueComment, IssueCommentReaction, IssueFollower, IssueRelationship, IssueTag, Notification, OrganizationMember, Project, ProjectStatus, PullRequest, Tag, User, Workspace, }; use crate::shape_definition::ShapeDefinition; // ============================================================================= // Organization-scoped shapes // ============================================================================= pub const PROJECTS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECTS_SHAPE", table: "projects", where_clause: r#""organization_id" = $1"#, url: "/shape/projects", params: ["organization_id"], ); pub const NOTIFICATIONS_SHAPE: ShapeDefinition = crate::define_shape!( name: "NOTIFICATIONS_SHAPE", table: "notifications", where_clause: r#""user_id" = $1"#, url: "/shape/notifications", params: ["user_id"], ); pub const ORGANIZATION_MEMBERS_SHAPE: ShapeDefinition = crate::define_shape!( name: "ORGANIZATION_MEMBERS_SHAPE", table: "organization_member_metadata", where_clause: r#""organization_id" = $1"#, url: "/shape/organization_members", params: ["organization_id"], ); pub const USERS_SHAPE: ShapeDefinition = crate::define_shape!( name: "USERS_SHAPE", table: "users", where_clause: r#""id" IN (SELECT user_id FROM organization_member_metadata WHERE "organization_id" = $1)"#, url: "/shape/users", params: ["organization_id"], ); // ============================================================================= // Project-scoped shapes // ============================================================================= pub const PROJECT_TAGS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_TAGS_SHAPE", table: "tags", where_clause: r#""project_id" = $1"#, url: "/shape/project/{project_id}/tags", params: ["project_id"], ); pub const PROJECT_PROJECT_STATUSES_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_PROJECT_STATUSES_SHAPE", table: "project_statuses", where_clause: r#""project_id" = $1"#, url: "/shape/project/{project_id}/project_statuses", params: ["project_id"], ); pub const PROJECT_ISSUES_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_ISSUES_SHAPE", table: "issues", where_clause: r#""project_id" = $1"#, url: "/shape/project/{project_id}/issues", params: ["project_id"], ); pub const USER_WORKSPACES_SHAPE: ShapeDefinition = crate::define_shape!( name: "USER_WORKSPACES_SHAPE", table: "workspaces", where_clause: r#""owner_user_id" = $1"#, url: "/shape/user/workspaces", params: ["owner_user_id"], ); pub const PROJECT_WORKSPACES_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_WORKSPACES_SHAPE", table: "workspaces", where_clause: r#""project_id" = $1"#, url: "/shape/project/{project_id}/workspaces", params: ["project_id"], ); // ============================================================================= // Issue-related shapes (streamed at project level) // ============================================================================= pub const PROJECT_ISSUE_ASSIGNEES_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_ISSUE_ASSIGNEES_SHAPE", table: "issue_assignees", where_clause: r#""issue_id" IN (SELECT id FROM issues WHERE "project_id" = $1)"#, url: "/shape/project/{project_id}/issue_assignees", params: ["project_id"], ); pub const PROJECT_ISSUE_FOLLOWERS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_ISSUE_FOLLOWERS_SHAPE", table: "issue_followers", where_clause: r#""issue_id" IN (SELECT id FROM issues WHERE "project_id" = $1)"#, url: "/shape/project/{project_id}/issue_followers", params: ["project_id"], ); pub const PROJECT_ISSUE_TAGS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_ISSUE_TAGS_SHAPE", table: "issue_tags", where_clause: r#""issue_id" IN (SELECT id FROM issues WHERE "project_id" = $1)"#, url: "/shape/project/{project_id}/issue_tags", params: ["project_id"], ); pub const PROJECT_ISSUE_RELATIONSHIPS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_ISSUE_RELATIONSHIPS_SHAPE", table: "issue_relationships", where_clause: r#""issue_id" IN (SELECT id FROM issues WHERE "project_id" = $1)"#, url: "/shape/project/{project_id}/issue_relationships", params: ["project_id"], ); pub const PROJECT_PULL_REQUESTS_SHAPE: ShapeDefinition = crate::define_shape!( name: "PROJECT_PULL_REQUESTS_SHAPE", table: "pull_requests", where_clause: r#""issue_id" IN (SELECT id FROM issues WHERE "project_id" = $1)"#, url: "/shape/project/{project_id}/pull_requests", params: ["project_id"], ); // ============================================================================= // Issue-scoped shapes // ============================================================================= pub const ISSUE_COMMENTS_SHAPE: ShapeDefinition = crate::define_shape!( name: "ISSUE_COMMENTS_SHAPE", table: "issue_comments", where_clause: r#""issue_id" = $1"#, url: "/shape/issue/{issue_id}/comments", params: ["issue_id"], ); pub const ISSUE_REACTIONS_SHAPE: ShapeDefinition = crate::define_shape!( name: "ISSUE_REACTIONS_SHAPE", table: "issue_comment_reactions", where_clause: r#""comment_id" IN (SELECT id FROM issue_comments WHERE "issue_id" = $1)"#, url: "/shape/issue/{issue_id}/reactions", params: ["issue_id"], ); ================================================ FILE: crates/remote/src/shared_key_auth.rs ================================================ // SharedKey authorization policy for connecting to Azurite (local Azure Storage emulator). // Only used for local development — production uses Entra ID. // Based on: https://github.com/Azure/azure-sdk-for-rust/issues/2975#issuecomment-3538764202 use std::{borrow::Cow, sync::Arc}; use async_trait::async_trait; use azure_core::{ credentials::Secret, http::{ Context, Method, Request, Url, headers::{CONTENT_LENGTH, HeaderName, Headers}, policies::{Policy, PolicyResult}, }, }; use base64::prelude::*; use hmac::{Hmac, Mac}; use sha2::Sha256; #[derive(Debug)] pub struct SharedKeyAuthorizationPolicy { pub account: String, pub key: Secret, } #[async_trait] impl Policy for SharedKeyAuthorizationPolicy { async fn send( &self, ctx: &Context, request: &mut Request, next: &[Arc], ) -> PolicyResult { let auth = generate_authorization( request.headers(), request.url(), &request.method(), &self.account, &self.key, ); request.insert_header("authorization", auth); next[0].send(ctx, request, &next[1..]).await } } fn generate_authorization( h: &Headers, u: &Url, method: &Method, account: &str, key: &Secret, ) -> String { let str_to_sign = string_to_sign(account, h, u, method); let auth = hmac_sha256(&str_to_sign, key); format!("SharedKey {account}:{auth}") } fn string_to_sign(account: &str, h: &Headers, u: &Url, method: &Method) -> String { let content_length = h .get_optional_str(&CONTENT_LENGTH) .filter(|&v| v != "0") .unwrap_or_default(); format!( "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}{}", method.as_ref(), add_if_exists(h, &HeaderName::from_static("content-encoding")), add_if_exists(h, &HeaderName::from_static("content-language")), content_length, add_if_exists(h, &HeaderName::from_static("content-md5")), add_if_exists(h, &HeaderName::from_static("content-type")), add_if_exists(h, &HeaderName::from_static("date")), add_if_exists(h, &HeaderName::from_static("if-modified-since")), add_if_exists(h, &HeaderName::from_static("if-match")), add_if_exists(h, &HeaderName::from_static("if-none-match")), add_if_exists(h, &HeaderName::from_static("if-unmodified-since")), add_if_exists(h, &HeaderName::from_static("byte_range")), canonicalize_header(h), canonicalized_resource(account, u), ) } #[inline] fn add_if_exists<'a>(h: &'a Headers, key: &HeaderName) -> &'a str { h.get_optional_str(key).unwrap_or("") } fn canonicalize_header(headers: &Headers) -> String { let mut names: Vec<_> = headers .iter() .filter_map(|(k, _)| k.as_str().starts_with("x-ms").then_some(k)) .collect(); names.sort_unstable(); let mut result = String::new(); for header_name in names { let value = headers.get_optional_str(header_name).unwrap(); let name = header_name.as_str(); result = format!("{result}{name}:{value}\n"); } result } fn lexy_sort<'a>( pairs: impl Iterator, Cow<'a, str>)>, query_param: &str, ) -> Vec> { let mut values: Vec<_> = pairs .filter(|(k, _)| *k == query_param) .map(|(_, v)| v) .collect(); values.sort_unstable(); values } fn canonicalized_resource(account: &str, uri: &Url) -> String { let mut can_res = String::new(); can_res.push('/'); can_res.push_str(account); for p in uri.path_segments().into_iter().flatten() { can_res.push('/'); can_res.push_str(p); } can_res.push('\n'); let query_pairs: Vec<_> = uri.query_pairs().collect(); let mut qps = Vec::new(); for (q, _) in query_pairs { if !qps.iter().any(|x: &String| x == &*q) { qps.push(q.into_owned()); } } qps.sort(); for qparam in &qps { let ret = lexy_sort(uri.query_pairs(), qparam); can_res.push_str(&qparam.to_lowercase()); can_res.push(':'); for (i, item) in ret.iter().enumerate() { if i > 0 { can_res.push(','); } can_res.push_str(item); } can_res.push('\n'); } can_res[..can_res.len() - 1].to_owned() } pub fn hmac_sha256(data: &str, key: &Secret) -> String { let key = BASE64_STANDARD.decode(key.secret()).unwrap(); let mut hmac = Hmac::::new_from_slice(&key).unwrap(); hmac.update(data.as_bytes()); BASE64_STANDARD.encode(hmac.finalize().into_bytes()) } ================================================ FILE: crates/remote/src/state.rs ================================================ use std::sync::Arc; use sqlx::PgPool; use crate::{ analytics::AnalyticsService, auth::{JwtService, OAuthHandoffService, OAuthTokenValidator, ProviderRegistry}, azure_blob::AzureBlobService, billing::BillingService, config::RemoteServerConfig, github_app::GitHubAppService, mail::Mailer, r2::R2Service, }; #[derive(Clone)] pub struct AppState { pub pool: PgPool, pub config: RemoteServerConfig, pub jwt: Arc, pub mailer: Arc, pub server_public_base_url: String, pub http_client: reqwest::Client, handoff: Arc, oauth_token_validator: Arc, r2: Option, azure_blob: Option, github_app: Option>, billing: BillingService, analytics: Option, } impl AppState { #[allow(clippy::too_many_arguments)] pub fn new( pool: PgPool, config: RemoteServerConfig, jwt: Arc, handoff: Arc, oauth_token_validator: Arc, mailer: Arc, server_public_base_url: String, http_client: reqwest::Client, r2: Option, azure_blob: Option, github_app: Option>, billing: BillingService, analytics: Option, ) -> Self { Self { pool, config, jwt, mailer, server_public_base_url, http_client, handoff, oauth_token_validator, r2, azure_blob, github_app, billing, analytics, } } pub fn pool(&self) -> &PgPool { &self.pool } pub fn config(&self) -> &RemoteServerConfig { &self.config } pub fn jwt(&self) -> Arc { Arc::clone(&self.jwt) } pub fn handoff(&self) -> Arc { Arc::clone(&self.handoff) } pub fn providers(&self) -> Arc { self.handoff.providers() } pub fn oauth_token_validator(&self) -> Arc { Arc::clone(&self.oauth_token_validator) } pub fn r2(&self) -> Option<&R2Service> { self.r2.as_ref() } pub fn azure_blob(&self) -> Option<&AzureBlobService> { self.azure_blob.as_ref() } pub fn github_app(&self) -> Option<&GitHubAppService> { self.github_app.as_deref() } pub fn billing(&self) -> &BillingService { &self.billing } pub fn analytics(&self) -> Option<&AnalyticsService> { self.analytics.as_ref() } } ================================================ FILE: crates/review/Cargo.toml ================================================ [package] name = "review" version = "0.1.33" edition = "2024" publish = false [[bin]] name = "review" path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive", "env"] } tokio = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] } rustls = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tar = "0.4" flate2 = "1.0" indicatif = "0.17" anyhow = { workspace = true } thiserror = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } tempfile = "3.8" tracing = { workspace = true } tracing-subscriber = { workspace = true } utils = { path = "../utils" } dialoguer = "0.11" dirs = "5.0" toml = "0.8" ================================================ FILE: crates/review/src/api.rs ================================================ use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::debug; use uuid::Uuid; use crate::error::ReviewError; /// API client for the review service pub struct ReviewApiClient { client: Client, base_url: String, } /// Response from POST /review/init #[derive(Debug, Deserialize)] pub struct InitResponse { pub review_id: Uuid, pub upload_url: String, pub object_key: String, } /// Request body for POST /review/init #[derive(Debug, Serialize)] struct InitRequest { gh_pr_url: String, email: String, pr_title: String, } /// Request body for POST /review/start #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct StartRequest { pub id: String, pub title: String, pub description: String, pub org: String, pub repo: String, pub codebase_url: String, pub base_commit: String, } /// Response from GET /review/{id}/status #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StatusResponse { pub status: ReviewStatus, pub progress: Option, pub error: Option, } /// Possible review statuses #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReviewStatus { Queued, Extracting, Running, Completed, Failed, } impl std::fmt::Display for ReviewStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ReviewStatus::Queued => write!(f, "queued"), ReviewStatus::Extracting => write!(f, "extracting"), ReviewStatus::Running => write!(f, "running"), ReviewStatus::Completed => write!(f, "completed"), ReviewStatus::Failed => write!(f, "failed"), } } } impl ReviewApiClient { /// Create a new API client pub fn new(base_url: String) -> Self { Self { client: Client::new(), base_url, } } /// Initialize a review upload and get a presigned URL pub async fn init( &self, pr_url: &str, email: &str, pr_title: &str, ) -> Result { let url = format!("{}/v1/review/init", self.base_url); debug!("POST {url}"); let response = self .client .post(&url) .json(&InitRequest { gh_pr_url: pr_url.to_string(), email: email.to_string(), pr_title: pr_title.to_string(), }) .send() .await .map_err(|e| ReviewError::ApiError(e.to_string()))?; if !response.status().is_success() { let status = response.status(); let body = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(ReviewError::ApiError(format!("{status}: {body}"))); } let init_response: InitResponse = response .json() .await .map_err(|e| ReviewError::ApiError(e.to_string()))?; debug!("Review ID: {}", init_response.review_id); Ok(init_response) } /// Upload the tarball to the presigned URL pub async fn upload(&self, upload_url: &str, payload: Vec) -> Result<(), ReviewError> { debug!("PUT {} ({} bytes)", upload_url, payload.len()); let response = self .client .put(upload_url) .header("Content-Type", "application/gzip") .body(payload) .send() .await .map_err(|e| ReviewError::UploadFailed(e.to_string()))?; if !response.status().is_success() { let status = response.status(); let body = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(ReviewError::UploadFailed(format!("{status}: {body}"))); } Ok(()) } /// Start the review process pub async fn start(&self, request: StartRequest) -> Result<(), ReviewError> { let url = format!("{}/v1/review/start", self.base_url); debug!("POST {url}"); let response = self .client .post(&url) .json(&request) .send() .await .map_err(|e| ReviewError::ApiError(e.to_string()))?; if !response.status().is_success() { let status = response.status(); let body = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(ReviewError::ApiError(format!("{status}: {body}"))); } Ok(()) } /// Poll the review status pub async fn poll_status(&self, review_id: &str) -> Result { let url = format!("{}/v1/review/{}/status", self.base_url, review_id); debug!("GET {url}"); let response = self .client .get(&url) .send() .await .map_err(|e| ReviewError::ApiError(e.to_string()))?; if !response.status().is_success() { let status = response.status(); let body = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(ReviewError::ApiError(format!("{status}: {body}"))); } let status_response: StatusResponse = response .json() .await .map_err(|e| ReviewError::ApiError(e.to_string()))?; Ok(status_response) } /// Get the review URL for a given review ID pub fn review_url(&self, review_id: &str) -> String { format!("{}/review/{}", self.base_url, review_id) } } ================================================ FILE: crates/review/src/archive.rs ================================================ use std::{fs::File, path::Path}; use flate2::{Compression, write::GzEncoder}; use tar::Builder; use tracing::debug; use crate::error::ReviewError; /// Create a tar.gz archive from a directory pub fn create_tarball(source_dir: &Path) -> Result, ReviewError> { debug!("Creating tarball from {}", source_dir.display()); let mut buffer = Vec::new(); { let encoder = GzEncoder::new(&mut buffer, Compression::default()); let mut archive = Builder::new(encoder); add_directory_to_archive(&mut archive, source_dir, source_dir)?; let encoder = archive .into_inner() .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; encoder .finish() .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; } debug!("Created tarball: {} bytes", buffer.len()); Ok(buffer) } fn add_directory_to_archive( archive: &mut Builder, base_dir: &Path, current_dir: &Path, ) -> Result<(), ReviewError> { let entries = std::fs::read_dir(current_dir).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; for entry in entries { let entry = entry.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; let path = entry.path(); let relative_path = path .strip_prefix(base_dir) .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; let metadata = entry .metadata() .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; if metadata.is_dir() { // Recursively add directory contents add_directory_to_archive(archive, base_dir, &path)?; } else if metadata.is_file() { // Add file to archive let mut file = File::open(&path).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; archive .append_file(relative_path, &mut file) .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; } // Skip symlinks and other special files } Ok(()) } #[cfg(test)] mod tests { use tempfile::TempDir; use super::*; #[test] fn test_create_tarball() { let temp_dir = TempDir::new().unwrap(); let base = temp_dir.path(); // Create some test files std::fs::write(base.join("file1.txt"), "content1").unwrap(); std::fs::create_dir(base.join("subdir")).unwrap(); std::fs::write(base.join("subdir/file2.txt"), "content2").unwrap(); let tarball = create_tarball(base).expect("Should create tarball"); // Verify tarball is not empty assert!(!tarball.is_empty()); // Decompress and verify contents let decoder = flate2::read::GzDecoder::new(&tarball[..]); let mut archive = tar::Archive::new(decoder); let entries: Vec<_> = archive .entries() .unwrap() .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string()) .collect(); assert!(entries.contains(&"file1.txt".to_string())); assert!(entries.contains(&"subdir/file2.txt".to_string())); } } ================================================ FILE: crates/review/src/claude_session.rs ================================================ use std::{ fs::{self, File}, io::{BufRead, BufReader}, path::{Path, PathBuf}, time::SystemTime, }; use serde::Deserialize; use tracing::debug; use crate::error::ReviewError; /// Represents a Claude Code project directory #[derive(Debug, Clone)] pub struct ClaudeProject { pub path: PathBuf, pub name: String, pub git_branch: Option, pub first_prompt: Option, pub session_count: usize, pub modified_at: SystemTime, } /// Represents a single session file within a project #[derive(Debug, Clone)] pub struct ClaudeSession { pub path: PathBuf, pub git_branch: Option, pub first_prompt: Option, pub modified_at: SystemTime, } /// A JSONL record for metadata extraction #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct JsonlRecord { git_branch: Option, message: Option, } /// Message within a JSONL record #[derive(Debug, Deserialize)] struct JsonlMessage { role: Option, content: Option, } /// Get the Claude projects directory path (~/.claude/projects) pub fn get_claude_projects_dir() -> Option { dirs::home_dir().map(|home| home.join(".claude").join("projects")) } /// Discover all Claude projects, sorted by modification time (most recent first) /// Aggregates session metadata (git_branch, first_prompt, session_count) from each project's sessions pub fn discover_projects() -> Result, ReviewError> { let projects_dir = get_claude_projects_dir().ok_or_else(|| { ReviewError::SessionDiscoveryFailed("Could not find home directory".into()) })?; if !projects_dir.exists() { debug!( "Claude projects directory does not exist: {:?}", projects_dir ); return Ok(Vec::new()); } let mut projects = Vec::new(); let entries = fs::read_dir(&projects_dir) .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; for entry in entries { let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; let path = entry.path(); if !path.is_dir() { continue; } let metadata = entry .metadata() .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); // Extract a friendly name from the directory name // e.g., "-private-var-...-worktrees-a04a-store-payloads-i" -> "store-payloads-i" let dir_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); let name = extract_project_name(dir_name); // Discover sessions to get aggregated metadata let sessions = discover_sessions_in_dir(&path)?; let session_count = sessions.len(); // Skip projects with no sessions if session_count == 0 { continue; } // Get metadata from the most recent session let most_recent = &sessions[0]; // Already sorted by modification time let git_branch = most_recent.git_branch.clone(); let first_prompt = most_recent.first_prompt.clone(); projects.push(ClaudeProject { path, name, git_branch, first_prompt, session_count, modified_at, }); } // Sort by modification time, most recent first projects.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(projects) } /// Extract a friendly project name from the Claude directory name fn extract_project_name(dir_name: &str) -> String { // Directory names look like: // "-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i" // We want to extract the meaningful part after "worktrees-" if let Some(idx) = dir_name.find("worktrees-") { let after_worktrees = &dir_name[idx + "worktrees-".len()..]; // Skip the short hash prefix (e.g., "a04a-") if let Some(dash_idx) = after_worktrees.find('-') { return after_worktrees[dash_idx + 1..].to_string(); } return after_worktrees.to_string(); } // Fallback: use last segment after the final dash dir_name.rsplit('-').next().unwrap_or(dir_name).to_string() } /// Discover sessions in a project, excluding agent-* files pub fn discover_sessions(project: &ClaudeProject) -> Result, ReviewError> { discover_sessions_in_dir(&project.path) } /// Discover sessions in a directory, excluding agent-* files fn discover_sessions_in_dir(dir_path: &Path) -> Result, ReviewError> { let mut sessions = Vec::new(); let entries = fs::read_dir(dir_path).map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; for entry in entries { let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; let path = entry.path(); // Only process .jsonl files if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); // Skip agent-* files if file_name.starts_with("agent-") { continue; } let metadata = entry .metadata() .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); // Extract metadata from the JSONL file let (git_branch, first_prompt) = extract_session_metadata(&path); sessions.push(ClaudeSession { path, git_branch, first_prompt, modified_at, }); } // Sort by modification time, most recent first sessions.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(sessions) } /// Extract session metadata from a JSONL file /// Returns: (git_branch, first_prompt) fn extract_session_metadata(path: &Path) -> (Option, Option) { let file = match File::open(path) { Ok(f) => f, Err(_) => return (None, None), }; let reader = BufReader::new(file); let mut git_branch: Option = None; let mut first_prompt: Option = None; // Check first 50 lines for metadata for line in reader.lines().take(50) { let line = match line { Ok(l) => l, Err(_) => continue, }; if line.trim().is_empty() { continue; } if let Ok(record) = serde_json::from_str::(&line) { // Extract git branch if not already found if git_branch.is_none() && record.git_branch.is_some() { git_branch = record.git_branch; } // Extract first user prompt if not already found if first_prompt.is_none() && let Some(ref message) = record.message && message.role.as_deref() == Some("user") && let Some(ref content) = message.content { // Content can be a string or an array if let Some(text) = content.as_str() { first_prompt = Some(truncate_string(text, 60)); } } // Stop early if we have both if git_branch.is_some() && first_prompt.is_some() { break; } } } (git_branch, first_prompt) } /// Truncate a string to max length, adding "..." if truncated fn truncate_string(s: &str, max_len: usize) -> String { // Replace newlines with spaces for display let s = s.replace('\n', " "); if s.len() <= max_len { s } else { format!("{}...", &s[..max_len - 3]) } } /// Find projects matching a specific git branch using fuzzy matching /// Returns matching projects with all their sessions pub fn find_projects_by_branch( projects: &[ClaudeProject], target_branch: &str, ) -> Result)>, ReviewError> { let mut matches = Vec::new(); for project in projects { // Check if project's branch matches if let Some(ref project_branch) = project.git_branch && branches_match(target_branch, project_branch) { let sessions = discover_sessions(project)?; matches.push((project.clone(), sessions)); } } // Sort by modification time, most recent first matches.sort_by(|a, b| b.0.modified_at.cmp(&a.0.modified_at)); Ok(matches) } /// Check if two branch names match using fuzzy matching fn branches_match(target: &str, session_branch: &str) -> bool { let target_normalized = normalize_branch(target); let session_normalized = normalize_branch(session_branch); // Exact match after normalization if target_normalized == session_normalized { return true; } // Check if the slug portions match (e.g., "feature-auth" matches "vk/feature-auth") let target_slug = extract_branch_slug(&target_normalized); let session_slug = extract_branch_slug(&session_normalized); target_slug == session_slug && !target_slug.is_empty() } /// Normalize a branch name by stripping common prefixes fn normalize_branch(branch: &str) -> String { let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); branch.to_lowercase() } /// Extract the "slug" portion of a branch name /// e.g., "vk/a04a-store-payloads-i" -> "a04a-store-payloads-i" fn extract_branch_slug(branch: &str) -> String { // Split by '/' and take the last part branch.rsplit('/').next().unwrap_or(branch).to_string() } /// A record with timestamp for sorting struct TimestampedMessage { timestamp: String, message: serde_json::Value, } /// Concatenate multiple JSONL files into a single JSON array of messages. /// /// Filters to include only: /// - User messages (role = "user") /// - Assistant messages with text content (role = "assistant" with content[].type = "text") /// /// For assistant messages, only text content blocks are kept (tool_use, etc. are filtered out). pub fn concatenate_sessions_to_json(session_paths: &[PathBuf]) -> Result { let mut all_messages: Vec = Vec::new(); for path in session_paths { let file = File::open(path) .map_err(|e| ReviewError::JsonlParseFailed(format!("{}: {}", path.display(), e)))?; let reader = BufReader::new(file); for (line_num, line) in reader.lines().enumerate() { let line = line.map_err(|e| { ReviewError::JsonlParseFailed(format!("{}:{}: {}", path.display(), line_num + 1, e)) })?; if line.trim().is_empty() { continue; } let record: serde_json::Value = serde_json::from_str(&line).map_err(|e| { ReviewError::JsonlParseFailed(format!("{}:{}: {}", path.display(), line_num + 1, e)) })?; // Extract timestamp for sorting let timestamp = record .get("timestamp") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); // Extract and filter the message if let Some(message) = extract_filtered_message(&record) { all_messages.push(TimestampedMessage { timestamp, message }); } } } // Sort by timestamp all_messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); // Extract just the messages let messages: Vec = all_messages.into_iter().map(|m| m.message).collect(); serde_json::to_string(&messages).map_err(|e| ReviewError::JsonlParseFailed(e.to_string())) } /// Extract and filter a message from a JSONL record. /// /// Returns Some(message) if the record should be included, None otherwise. /// - User messages: include if content is a string, or if content array has text blocks /// - Assistant messages: include if content array has text blocks (filter out tool_use, etc.) fn extract_filtered_message(record: &serde_json::Value) -> Option { let message = record.get("message")?; let role = message.get("role")?.as_str()?; let content = message.get("content")?; match role { "user" => { // If content is a string, include directly if content.is_string() { return Some(message.clone()); } // If content is an array, filter to text blocks only if let Some(content_array) = content.as_array() { let text_blocks: Vec = content_array .iter() .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text")) .cloned() .collect(); // Skip if no text content (e.g., only tool_result) if text_blocks.is_empty() { return None; } // Create filtered message with only text content let mut filtered_message = serde_json::Map::new(); filtered_message.insert( "role".to_string(), serde_json::Value::String("user".to_string()), ); filtered_message .insert("content".to_string(), serde_json::Value::Array(text_blocks)); return Some(serde_json::Value::Object(filtered_message)); } None } "assistant" => { // Filter assistant messages to only include text content if let Some(content_array) = content.as_array() { // Filter to only text blocks let text_blocks: Vec = content_array .iter() .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text")) .cloned() .collect(); // Skip if no text content if text_blocks.is_empty() { return None; } // Create filtered message with only text content let mut filtered_message = serde_json::Map::new(); filtered_message.insert( "role".to_string(), serde_json::Value::String("assistant".to_string()), ); filtered_message .insert("content".to_string(), serde_json::Value::Array(text_blocks)); Some(serde_json::Value::Object(filtered_message)) } else { // Content is not an array (unusual), skip None } } _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_project_name() { assert_eq!( extract_project_name( "-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i" ), "store-payloads-i" ); assert_eq!( extract_project_name( "-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-1ff1-new-rust-binary" ), "new-rust-binary" ); } #[test] fn test_branches_match() { // Exact match assert!(branches_match("feature-auth", "feature-auth")); // With prefix assert!(branches_match("feature-auth", "vk/feature-auth")); assert!(branches_match("vk/feature-auth", "feature-auth")); // Slug matching assert!(branches_match( "a04a-store-payloads-i", "vk/a04a-store-payloads-i" )); // Case insensitive assert!(branches_match("Feature-Auth", "feature-auth")); // Non-matches assert!(!branches_match("feature-auth", "feature-other")); assert!(!branches_match("main", "feature-auth")); // Regression tests: substring matches should NOT match // (these were incorrectly matching before the fix) assert!(!branches_match("vk/d13f-remove-compare-c", "c")); assert!(!branches_match("vk/d13f-remove-compare-c", "compare")); assert!(!branches_match("feature-auth", "auth")); assert!(!branches_match("feature-auth", "feature")); } #[test] fn test_normalize_branch() { assert_eq!(normalize_branch("refs/heads/main"), "main"); assert_eq!(normalize_branch("Feature-Auth"), "feature-auth"); assert_eq!(normalize_branch("vk/feature-auth"), "vk/feature-auth"); } #[test] fn test_extract_branch_slug() { assert_eq!(extract_branch_slug("vk/feature-auth"), "feature-auth"); assert_eq!(extract_branch_slug("feature-auth"), "feature-auth"); assert_eq!( extract_branch_slug("user/prefix/feature-auth"), "feature-auth" ); } } ================================================ FILE: crates/review/src/config.rs ================================================ use std::path::PathBuf; use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub email: Option, } impl Config { /// Get the path to the config file (~/.config/vibe-kanban/review.toml) fn config_path() -> Option { dirs::config_dir().map(|p| p.join("vibe-kanban").join("review.toml")) } /// Load config from disk, returning default if file doesn't exist pub fn load() -> Self { let Some(path) = Self::config_path() else { return Self::default(); }; if !path.exists() { return Self::default(); } match std::fs::read_to_string(&path) { Ok(contents) => toml::from_str(&contents).unwrap_or_default(), Err(_) => Self::default(), } } /// Save config to disk pub fn save(&self) -> std::io::Result<()> { let Some(path) = Self::config_path() else { return Ok(()); }; // Create parent directories if needed if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let contents = toml::to_string_pretty(self).unwrap_or_default(); std::fs::write(&path, contents) } } ================================================ FILE: crates/review/src/error.rs ================================================ use thiserror::Error; #[derive(Debug, Error)] pub enum ReviewError { #[error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")] GhNotInstalled, #[error("GitHub CLI is not authenticated. Run 'gh auth login' first.")] GhNotAuthenticated, #[error("Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123")] InvalidPrUrl, #[error("Failed to get PR information: {0}")] PrInfoFailed(String), #[error("Failed to clone repository: {0}")] CloneFailed(String), #[error("Failed to checkout PR: {0}")] CheckoutFailed(String), #[error("Failed to create archive: {0}")] ArchiveFailed(String), #[error("API request failed: {0}")] ApiError(String), #[error("Upload failed: {0}")] UploadFailed(String), #[error("Review failed: {0}")] ReviewFailed(String), #[error("Review timed out after 10 minutes")] Timeout, #[error("Failed to discover Claude Code sessions: {0}")] SessionDiscoveryFailed(String), #[error("Failed to parse JSONL file: {0}")] JsonlParseFailed(String), } ================================================ FILE: crates/review/src/github.rs ================================================ use std::{path::Path, process::Command}; use serde::Deserialize; use tracing::debug; use utils::command_ext::NoWindowExt; use crate::error::ReviewError; /// Information about a pull request #[derive(Debug)] pub struct PrInfo { pub owner: String, pub repo: String, pub title: String, pub description: String, pub base_commit: String, pub head_commit: String, pub head_ref_name: String, } /// Response from `gh pr view --json` #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GhPrView { title: String, body: String, base_ref_oid: String, head_ref_oid: String, head_ref_name: String, } /// Response from `gh api /repos/{owner}/{repo}/pulls/{number}` /// Used as fallback for older gh CLI versions that don't support baseRefOid/headRefOid fields #[derive(Debug, Deserialize)] struct GhApiPr { title: String, body: Option, base: GhApiRef, head: GhApiRef, } #[derive(Debug, Deserialize)] struct GhApiRef { sha: String, #[serde(rename = "ref")] ref_name: String, } /// Parse a GitHub PR URL to extract owner, repo, and PR number /// /// Expected format: https://github.com/owner/repo/pull/123 pub fn parse_pr_url(url: &str) -> Result<(String, String, i64), ReviewError> { let url = url.trim(); // Remove trailing slashes let url = url.trim_end_matches('/'); // Try to parse as URL let parts: Vec<&str> = url.split('/').collect(); // Find the index of "github.com" and then extract owner/repo/pull/number let github_idx = parts .iter() .position(|&p| p == "github.com") .ok_or(ReviewError::InvalidPrUrl)?; // We need at least: github.com / owner / repo / pull / number if parts.len() < github_idx + 5 { return Err(ReviewError::InvalidPrUrl); } let owner = parts[github_idx + 1].to_string(); let repo = parts[github_idx + 2].to_string(); if parts[github_idx + 3] != "pull" { return Err(ReviewError::InvalidPrUrl); } let pr_number: i64 = parts[github_idx + 4] .parse() .map_err(|_| ReviewError::InvalidPrUrl)?; if owner.is_empty() || repo.is_empty() || pr_number <= 0 { return Err(ReviewError::InvalidPrUrl); } Ok((owner, repo, pr_number)) } /// Check if the GitHub CLI is installed fn ensure_gh_available() -> Result<(), ReviewError> { let output = Command::new("which") .arg("gh") .no_window() .output() .map_err(|_| ReviewError::GhNotInstalled)?; if !output.status.success() { return Err(ReviewError::GhNotInstalled); } Ok(()) } /// Get PR information using `gh api` (REST API) /// This is used as a fallback for older gh CLI versions that don't support /// the baseRefOid/headRefOid fields in `gh pr view --json` fn get_pr_info_via_api(owner: &str, repo: &str, pr_number: i64) -> Result { debug!("Fetching PR info via gh api for {owner}/{repo}#{pr_number}"); let output = Command::new("gh") .args(["api", &format!("repos/{owner}/{repo}/pulls/{pr_number}")]) .no_window() .output() .map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let lower = stderr.to_ascii_lowercase(); if lower.contains("authentication") || lower.contains("gh auth login") || lower.contains("unauthorized") { return Err(ReviewError::GhNotAuthenticated); } return Err(ReviewError::PrInfoFailed(stderr.to_string())); } let stdout = String::from_utf8_lossy(&output.stdout); let api_pr: GhApiPr = serde_json::from_str(&stdout).map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?; Ok(PrInfo { owner: owner.to_string(), repo: repo.to_string(), title: api_pr.title, description: api_pr.body.unwrap_or_default(), base_commit: api_pr.base.sha, head_commit: api_pr.head.sha, head_ref_name: api_pr.head.ref_name, }) } /// Get PR information using `gh pr view` pub fn get_pr_info(owner: &str, repo: &str, pr_number: i64) -> Result { ensure_gh_available()?; debug!("Fetching PR info for {owner}/{repo}#{pr_number}"); let output = Command::new("gh") .args([ "pr", "view", &pr_number.to_string(), "--repo", &format!("{owner}/{repo}"), "--json", "title,body,baseRefOid,headRefOid,headRefName", ]) .no_window() .output() .map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let lower = stderr.to_ascii_lowercase(); // Check for old gh CLI version that doesn't support these JSON fields if lower.contains("unknown json field") { debug!("gh pr view --json failed with unknown field, falling back to gh api"); return get_pr_info_via_api(owner, repo, pr_number); } if lower.contains("authentication") || lower.contains("gh auth login") || lower.contains("unauthorized") { return Err(ReviewError::GhNotAuthenticated); } return Err(ReviewError::PrInfoFailed(stderr.to_string())); } let stdout = String::from_utf8_lossy(&output.stdout); let pr_view: GhPrView = serde_json::from_str(&stdout).map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?; Ok(PrInfo { owner: owner.to_string(), repo: repo.to_string(), title: pr_view.title, description: pr_view.body, base_commit: pr_view.base_ref_oid, head_commit: pr_view.head_ref_oid, head_ref_name: pr_view.head_ref_name, }) } /// Clone a repository using `gh repo clone` pub fn clone_repo(owner: &str, repo: &str, target_dir: &Path) -> Result<(), ReviewError> { ensure_gh_available()?; debug!("Cloning {owner}/{repo} to {}", target_dir.display()); let output = Command::new("gh") .args([ "repo", "clone", &format!("{owner}/{repo}"), target_dir .to_str() .ok_or_else(|| ReviewError::CloneFailed("Invalid target path".to_string()))?, ]) .no_window() .output() .map_err(|e| ReviewError::CloneFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ReviewError::CloneFailed(stderr.to_string())); } Ok(()) } /// Checkout a specific commit by SHA /// /// This is more reliable than `gh pr checkout` because it works even when /// the PR's branch has been deleted (common for merged PRs). pub fn checkout_commit(commit_sha: &str, repo_dir: &Path) -> Result<(), ReviewError> { debug!("Fetching commit {commit_sha} in {}", repo_dir.display()); // First, fetch the specific commit let output = Command::new("git") .args(["fetch", "origin", commit_sha]) .current_dir(repo_dir) .no_window() .output() .map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ReviewError::CheckoutFailed(format!( "Failed to fetch commit: {stderr}" ))); } debug!("Checking out commit {commit_sha}"); // Then checkout the commit let output = Command::new("git") .args(["checkout", commit_sha]) .current_dir(repo_dir) .no_window() .output() .map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ReviewError::CheckoutFailed(format!( "Failed to checkout commit: {stderr}" ))); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_pr_url_valid() { let (owner, repo, pr) = parse_pr_url("https://github.com/anthropics/claude-code/pull/123") .expect("Should parse valid URL"); assert_eq!(owner, "anthropics"); assert_eq!(repo, "claude-code"); assert_eq!(pr, 123); } #[test] fn test_parse_pr_url_with_trailing_slash() { let (owner, repo, pr) = parse_pr_url("https://github.com/owner/repo/pull/456/").expect("Should parse"); assert_eq!(owner, "owner"); assert_eq!(repo, "repo"); assert_eq!(pr, 456); } #[test] fn test_parse_pr_url_invalid_format() { assert!(parse_pr_url("https://github.com/owner/repo").is_err()); assert!(parse_pr_url("https://github.com/owner/repo/issues/123").is_err()); assert!(parse_pr_url("https://gitlab.com/owner/repo/pull/123").is_err()); assert!(parse_pr_url("not a url").is_err()); } } ================================================ FILE: crates/review/src/main.rs ================================================ mod api; mod archive; mod claude_session; mod config; mod error; mod github; mod session_selector; use std::time::Duration; use anyhow::Result; use api::{ReviewApiClient, ReviewStatus, StartRequest}; use clap::Parser; use error::ReviewError; use github::{checkout_commit, clone_repo, get_pr_info, parse_pr_url}; use indicatif::{ProgressBar, ProgressStyle}; use tempfile::TempDir; use tracing::debug; use tracing_subscriber::EnvFilter; const DEFAULT_API_URL: &str = "https://api.vibekanban.com"; const POLL_INTERVAL: Duration = Duration::from_secs(10); const TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes const BANNER: &str = r#" ██████╗ ███████╗██╗ ██╗██╗███████╗██╗ ██╗ ███████╗ █████╗ ███████╗████████╗ ██╔══██╗██╔════╝██║ ██║██║██╔════╝██║ ██║ ██╔════╝██╔══██╗██╔════╝╚══██╔══╝ ██████╔╝█████╗ ██║ ██║██║█████╗ ██║ █╗ ██║ █████╗ ███████║███████╗ ██║ ██╔══██╗██╔══╝ ╚██╗ ██╔╝██║██╔══╝ ██║███╗██║ ██╔══╝ ██╔══██║╚════██║ ██║ ██║ ██║███████╗ ╚████╔╝ ██║███████╗╚███╔███╔╝██╗██║ ██║ ██║███████║ ██║ ╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ "#; #[derive(Parser, Debug)] #[command(name = "review")] #[command( about = "Vibe-Kanban Review helps you review GitHub pull requests by turning them into a clear, story-driven summary instead of a wall of diffs. You provide a pull request URL, optionally link a Claude Code project for additional context, and it builds a narrative that highlights key events and important decisions, helping you prioritise what actually needs attention. It's particularly useful when reviewing large amounts of AI-generated code. Note that code is uploaded to and processed on Vibe-Kanban servers using AI." )] #[command(version)] struct Args { /// GitHub PR URL (e.g., https://github.com/owner/repo/pull/123) pr_url: String, /// Enable verbose output #[arg(short, long, default_value_t = false)] verbose: bool, /// API base URL #[arg(long, env = "REVIEW_API_URL", default_value = DEFAULT_API_URL)] api_url: String, } fn show_disclaimer() { println!(); println!( "DISCLAIMER: Your code will be processed on our secure remote servers, all artefacts (code, AI logs, etc...) will be deleted after 14 days." ); println!(); println!("Full terms and conditions and privacy policy: https://review.fast/terms"); println!(); println!("Press Enter to accept and continue..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).ok(); } fn prompt_email(config: &mut config::Config) -> String { use dialoguer::Input; let mut input: Input = Input::new().with_prompt("Email address (we'll send a link to the review here, no spam)"); if let Some(ref saved_email) = config.email { input = input.default(saved_email.clone()); } let email: String = input.interact_text().expect("Failed to read email"); // Save email for next time config.email = Some(email.clone()); if let Err(e) = config.save() { debug!("Failed to save config: {}", e); } email } fn create_spinner(message: &str) -> ProgressBar { let spinner = ProgressBar::new_spinner(); spinner.set_style( ProgressStyle::default_spinner() .template("{spinner:.green} {msg}") .expect("Invalid spinner template"), ); spinner.set_message(message.to_string()); spinner.enable_steady_tick(Duration::from_millis(100)); spinner } #[tokio::main] async fn main() -> Result<()> { // Install rustls crypto provider before any TLS operations rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); let args = Args::parse(); // Initialize tracing let filter = if args.verbose { EnvFilter::new("debug") } else { EnvFilter::new("warn") }; tracing_subscriber::fmt().with_env_filter(filter).init(); println!("{}", BANNER); show_disclaimer(); debug!("Args: {:?}", args); // Run the main flow and handle errors if let Err(e) = run(args).await { eprintln!("Error: {e}"); std::process::exit(1); } Ok(()) } async fn run(args: Args) -> Result<(), ReviewError> { // 1. Load config and prompt for email let mut config = config::Config::load(); let email = prompt_email(&mut config); // 2. Parse PR URL let spinner = create_spinner("Parsing PR URL..."); let (owner, repo, pr_number) = parse_pr_url(&args.pr_url)?; spinner.finish_with_message(format!("PR: {owner}/{repo}#{pr_number}")); // 3. Get PR info let spinner = create_spinner("Fetching PR information..."); let pr_info = get_pr_info(&owner, &repo, pr_number)?; spinner.finish_with_message(format!("PR: {}", pr_info.title)); // 4. Select Claude Code session (optional) let session_files = match session_selector::select_session(&pr_info.head_ref_name) { Ok(session_selector::SessionSelection::Selected(files)) => { println!(" Selected {} session file(s)", files.len()); Some(files) } Ok(session_selector::SessionSelection::Skipped) => { println!(" Skipping project attachment"); None } Err(e) => { debug!("Session selection error: {}", e); println!(" No sessions found"); None } }; // 5. Clone repository to temp directory let temp_dir = TempDir::new().map_err(|e| ReviewError::CloneFailed(e.to_string()))?; let repo_dir = temp_dir.path().join(&repo); let spinner = create_spinner("Cloning repository..."); clone_repo(&owner, &repo, &repo_dir)?; spinner.finish_with_message("Repository cloned"); // 6. Checkout PR head commit let spinner = create_spinner("Checking out PR..."); checkout_commit(&pr_info.head_commit, &repo_dir)?; spinner.finish_with_message("PR checked out"); // 7. Create tarball (with optional session data) let spinner = create_spinner("Creating archive..."); // If sessions were selected, write .agent-messages.json to repo root if let Some(ref files) = session_files { let json_content = claude_session::concatenate_sessions_to_json(files)?; let agent_messages_path = repo_dir.join(".agent-messages.json"); std::fs::write(&agent_messages_path, json_content) .map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?; } let payload = archive::create_tarball(&repo_dir)?; let size_mb = payload.len() as f64 / 1_048_576.0; spinner.finish_with_message(format!("Archive created ({size_mb:.2} MB)")); // 8. Initialize review let client = ReviewApiClient::new(args.api_url.clone()); let spinner = create_spinner("Initializing review..."); let init_response = client.init(&args.pr_url, &email, &pr_info.title).await?; spinner.finish_with_message(format!("Review ID: {}", init_response.review_id)); // 9. Upload archive let spinner = create_spinner("Uploading archive..."); client.upload(&init_response.upload_url, payload).await?; spinner.finish_with_message("Upload complete"); // 10. Start review let spinner = create_spinner("Starting review..."); let codebase_url = format!("r2://{}", init_response.object_key); client .start(StartRequest { id: init_response.review_id.to_string(), title: pr_info.title, description: pr_info.description, org: pr_info.owner, repo: pr_info.repo, codebase_url, base_commit: pr_info.base_commit, }) .await?; spinner.finish_with_message(format!("Review started, we'll send you an email at {} when the review is ready. This can take a few minutes, you may now close the terminal", email)); // 11. Poll for completion let spinner = create_spinner("Review in progress..."); let start_time = std::time::Instant::now(); loop { tokio::time::sleep(POLL_INTERVAL).await; // Check for timeout if start_time.elapsed() > TIMEOUT { spinner.finish_with_message("Timed out"); return Err(ReviewError::Timeout); } let status = client .poll_status(&init_response.review_id.to_string()) .await?; match status.status { ReviewStatus::Completed => { spinner.finish_with_message("Review completed!"); break; } ReviewStatus::Failed => { spinner.finish_with_message("Review failed"); let error_msg = status.error.unwrap_or_else(|| "Unknown error".to_string()); return Err(ReviewError::ReviewFailed(error_msg)); } _ => { let progress = status.progress.unwrap_or_else(|| status.status.to_string()); spinner.set_message(format!("Review in progress: {progress}")); } } } // 12. Print result URL let review_url = client.review_url(&init_response.review_id.to_string()); println!("\nReview available at:"); println!(" {review_url}"); Ok(()) } ================================================ FILE: crates/review/src/session_selector.rs ================================================ use std::{path::PathBuf, time::SystemTime}; use dialoguer::{Select, theme::ColorfulTheme}; use tracing::debug; use crate::{ claude_session::{ ClaudeProject, discover_projects, discover_sessions, find_projects_by_branch, }, error::ReviewError, }; /// Result of session selection process pub enum SessionSelection { /// User selected session files to include (all sessions from a project) Selected(Vec), /// User chose to skip session attachment Skipped, } /// Prompt user to select a Claude Code project /// /// Flow: /// 1. Try auto-match by branch name /// 2. If match found, confirm with user /// 3. If no match or user declines, show scrollable project list /// 4. Allow user to skip entirely /// /// When a project is selected, ALL sessions from that project are included. pub fn select_session(pr_branch: &str) -> Result { debug!( "Looking for Claude Code projects matching branch: {}", pr_branch ); let projects = discover_projects()?; if projects.is_empty() { debug!("No Claude Code projects found"); return Ok(SessionSelection::Skipped); } // Try auto-match by branch let matches = find_projects_by_branch(&projects, pr_branch)?; if !matches.is_empty() { // Found a matching project, ask for confirmation let (project, sessions) = &matches[0]; println!(); println!(); println!( "Found matching Claude Code project for branch '{}'", pr_branch ); println!(" Project: {}", project.name); if let Some(ref prompt) = project.first_prompt { println!(" \"{}\"", prompt); } println!( " {} session{} · Last modified: {}", project.session_count, if project.session_count == 1 { "" } else { "s" }, format_time_ago(project.modified_at) ); println!(); let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Use this project to improve review quality?") .items(&[ "Yes, use this project", "No, choose a different project", "Skip (generate review from just code changes)", ]) .default(0) .interact() .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; match selection { 0 => { // Yes, use all sessions from this project let paths: Vec = sessions.iter().map(|s| s.path.clone()).collect(); return Ok(SessionSelection::Selected(paths)); } 2 => { // Skip return Ok(SessionSelection::Skipped); } _ => { // Fall through to manual selection } } } // Manual selection: select a project select_project(&projects) } /// Manual project selection - returns all sessions from selected project fn select_project(projects: &[ClaudeProject]) -> Result { // Build project list with rich metadata let mut items: Vec = Vec::new(); items.push("Skip (no project)\n".to_string()); items.extend(projects.iter().map(format_project_item)); items.push("Skip (no project)\n".to_string()); println!(); println!(); let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Select a Claude Code project to improve review quality") .items(&items) .default(0) .max_length(5) .interact() .map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?; // Skip option if selection == 0 || selection == items.len() - 1 { return Ok(SessionSelection::Skipped); } let project = &projects[selection]; let sessions = discover_sessions(project)?; // Return all session paths from this project let paths: Vec = sessions.iter().map(|s| s.path.clone()).collect(); Ok(SessionSelection::Selected(paths)) } /// Format a project item for display in the selection list fn format_project_item(project: &ClaudeProject) -> String { let prompt_line = project .first_prompt .as_ref() .map(|p| format!("\n \"{}\"", p)) .unwrap_or_default(); let branch = project .git_branch .as_ref() .map(|b| format!("branch: {}", b)) .unwrap_or_else(|| "no branch".to_string()); format!( "{}{}\n {} · {} session{} · {}\n", project.name, prompt_line, branch, project.session_count, if project.session_count == 1 { "" } else { "s" }, format_time_ago(project.modified_at) ) } /// Format a SystemTime as a human-readable "time ago" string fn format_time_ago(time: SystemTime) -> String { let now = SystemTime::now(); let duration = now.duration_since(time).unwrap_or_default(); let secs = duration.as_secs(); if secs < 60 { "just now".to_string() } else if secs < 3600 { let mins = secs / 60; format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" }) } else if secs < 86400 { let hours = secs / 3600; format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) } else { let days = secs / 86400; format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) } } ================================================ FILE: crates/server/Cargo.toml ================================================ [package] name = "server" version = "0.1.33" edition = "2024" default-run = "server" [lints.clippy] uninlined-format-args = "allow" [dependencies] api-types = { path = "../api-types" } deployment = { path = "../deployment" } executors = { path = "../executors" } local-deployment = { path = "../local-deployment" } utils = { path = "../utils" } git = { path = "../git" } git-host = { path = "../git-host" } db = { path = "../db" } services = { path = "../services" } worktree-manager = { path = "../worktree-manager" } workspace-manager = { path = "../workspace-manager" } relay-tunnel = { path = "../relay-tunnel" } trusted-key-auth = { path = "../trusted-key-auth" } tokio = { workspace = true } shlex = "1.3.0" tokio-util = { version = "0.7", features = ["io"] } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } tower-http = { workspace = true } schemars = { workspace = true } sentry = { version = "0.46.2", default-features = false, features = ["anyhow", "backtrace", "panic", "debug-images", "reqwest", "rustls"] } reqwest = { workspace = true } rustls = { workspace = true } aws-lc-sys = { workspace = true } aws-lc-rs = { workspace = true } strip-ansi-escapes = "0.2.1" thiserror = { workspace = true } os_info = "3.12.0" futures-util = "0.3" http = "1" base64 = "0.22" git2 = { workspace = true } mime_guess = "2.0" rust-embed = "8.2" url = "2.5" rand = { version = "0.8", features = ["std"] } sha2 = "0.10" tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } [build-dependencies] dotenv = "0.15" [dev-dependencies] tempfile = "3" [features] default = [] qa-mode = ["services/qa-mode", "executors/qa-mode"] ================================================ FILE: crates/server/build.rs ================================================ use std::{fs, path::Path}; fn main() { // Load .env from the workspace root let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let env_file = workspace_root.join(".env"); dotenv::from_path(&env_file).ok(); // Re-run build script when these env vars or .env file change println!("cargo:rerun-if-env-changed=POSTHOG_API_KEY"); println!("cargo:rerun-if-env-changed=POSTHOG_API_ENDPOINT"); println!("cargo:rerun-if-env-changed=VK_SHARED_API_BASE"); println!("cargo:rerun-if-env-changed=SENTRY_DSN"); if env_file.exists() { println!("cargo:rerun-if-changed={}", env_file.display()); } if let Ok(api_key) = std::env::var("POSTHOG_API_KEY") { println!("cargo:rustc-env=POSTHOG_API_KEY={}", api_key); } if let Ok(api_endpoint) = std::env::var("POSTHOG_API_ENDPOINT") { println!("cargo:rustc-env=POSTHOG_API_ENDPOINT={}", api_endpoint); } if let Ok(vk_shared_api_base) = std::env::var("VK_SHARED_API_BASE") { println!("cargo:rustc-env=VK_SHARED_API_BASE={}", vk_shared_api_base); } if let Ok(vk_shared_relay_api_base) = std::env::var("VK_SHARED_RELAY_API_BASE") { println!( "cargo:rustc-env=VK_SHARED_RELAY_API_BASE={}", vk_shared_relay_api_base ); } // Create packages/local-web/dist directory if it doesn't exist let dist_path = Path::new("../../packages/local-web/dist"); if !dist_path.exists() { println!("cargo:warning=Creating dummy packages/local-web/dist directory for compilation"); fs::create_dir_all(dist_path).unwrap(); // Create a dummy index.html let dummy_html = r#" Build web app first

Please build @vibe/local-web first

"#; fs::write(dist_path.join("index.html"), dummy_html).unwrap(); } } ================================================ FILE: crates/server/src/bin/generate_types.rs ================================================ use std::{collections::HashMap, env, fs, path::Path}; use schemars::{JsonSchema, Schema, SchemaGenerator, generate::SchemaSettings}; use services::services::config::{DEFAULT_COMMIT_REMINDER_PROMPT, DEFAULT_PR_DESCRIPTION_PROMPT}; use ts_rs::TS; fn generate_types_content() -> String { // 4. Friendly banner const HEADER: &str = "// This file was generated by `crates/core/src/bin/generate_types.rs`.\n // Do not edit this file manually.\n // If you are an AI, and you absolutely have to edit this file, please confirm with the user first."; let decls: Vec = vec![ db::models::repo::Repo::decl(), db::models::project::Project::decl(), db::models::repo::UpdateRepo::decl(), db::models::repo::SearchResult::decl(), db::models::repo::SearchMatchType::decl(), db::models::workspace_repo::WorkspaceRepo::decl(), db::models::workspace_repo::CreateWorkspaceRepo::decl(), db::models::workspace_repo::RepoWithTargetBranch::decl(), db::models::tag::Tag::decl(), db::models::tag::CreateTag::decl(), db::models::tag::UpdateTag::decl(), db::models::scratch::DraftFollowUpData::decl(), db::models::scratch::DraftWorkspaceData::decl(), db::models::scratch::DraftWorkspaceAttachment::decl(), db::models::scratch::DraftWorkspaceLinkedIssue::decl(), db::models::scratch::DraftWorkspaceRepo::decl(), db::models::scratch::DraftIssueData::decl(), db::models::scratch::PreviewSettingsData::decl(), db::models::scratch::WorkspaceNotesData::decl(), db::models::scratch::WorkspacePanelStateData::decl(), db::models::scratch::WorkspacePrFilterData::decl(), db::models::scratch::WorkspaceSortByData::decl(), db::models::scratch::WorkspaceSortOrderData::decl(), db::models::scratch::WorkspaceFilterStateData::decl(), db::models::scratch::WorkspaceSortStateData::decl(), db::models::scratch::UiPreferencesData::decl(), db::models::scratch::ProjectRepoDefaultsData::decl(), db::models::scratch::ScratchPayload::decl(), db::models::scratch::ScratchType::decl(), db::models::scratch::Scratch::decl(), db::models::scratch::CreateScratch::decl(), db::models::scratch::UpdateScratch::decl(), db::models::workspace::Workspace::decl(), db::models::workspace::WorkspaceWithStatus::decl(), db::models::session::Session::decl(), db::models::execution_process::ExecutionProcess::decl(), db::models::execution_process::ExecutionProcessStatus::decl(), db::models::execution_process::ExecutionProcessRunReason::decl(), db::models::execution_process_repo_state::ExecutionProcessRepoState::decl(), db::models::merge::Merge::decl(), db::models::merge::DirectMerge::decl(), db::models::merge::PrMerge::decl(), db::models::merge::MergeStatus::decl(), db::models::merge::PullRequestInfo::decl(), services::services::approvals::ApprovalInfo::decl(), utils::approvals::ApprovalStatus::decl(), utils::approvals::QuestionAnswer::decl(), utils::approvals::QuestionStatus::decl(), utils::approvals::ApprovalOutcome::decl(), utils::approvals::ApprovalResponse::decl(), utils::diff::Diff::decl(), utils::diff::DiffChangeKind::decl(), utils::response::ApiResponse::<()>::decl(), api_types::LoginStatus::decl(), api_types::ProfileResponse::decl(), api_types::ProviderProfile::decl(), api_types::StatusResponse::decl(), api_types::MemberRole::decl(), api_types::InvitationStatus::decl(), api_types::Organization::decl(), api_types::OrganizationWithRole::decl(), api_types::ListOrganizationsResponse::decl(), api_types::GetOrganizationResponse::decl(), api_types::CreateOrganizationRequest::decl(), api_types::CreateOrganizationResponse::decl(), api_types::UpdateOrganizationRequest::decl(), api_types::Invitation::decl(), api_types::CreateInvitationRequest::decl(), api_types::CreateInvitationResponse::decl(), api_types::ListInvitationsResponse::decl(), api_types::GetInvitationResponse::decl(), api_types::AcceptInvitationResponse::decl(), api_types::RevokeInvitationRequest::decl(), api_types::OrganizationMemberInfo::decl(), api_types::OrganizationMemberWithProfile::decl(), api_types::ListMembersResponse::decl(), api_types::UpdateMemberRoleRequest::decl(), api_types::UpdateMemberRoleResponse::decl(), services::services::migration::MigrationRequest::decl(), services::services::migration::MigrationResponse::decl(), services::services::migration::MigrationReport::decl(), services::services::migration::EntityReport::decl(), services::services::migration::EntityError::decl(), server::routes::repo::RegisterRepoRequest::decl(), server::routes::repo::InitRepoRequest::decl(), server::routes::tags::TagSearchParams::decl(), server::routes::oauth::TokenResponse::decl(), server::routes::config::UserSystemInfo::decl(), server::routes::config::Environment::decl(), server::routes::config::McpServerQuery::decl(), server::routes::config::UpdateMcpServersBody::decl(), server::routes::config::GetMcpServerResponse::decl(), server::routes::config::CheckEditorAvailabilityQuery::decl(), server::routes::config::CheckEditorAvailabilityResponse::decl(), server::routes::config::CheckAgentAvailabilityQuery::decl(), server::routes::config::AgentPresetOptionsQuery::decl(), server::routes::oauth::CurrentUserResponse::decl(), server::routes::relay_auth::StartSpake2EnrollmentRequest::decl(), server::routes::relay_auth::FinishSpake2EnrollmentRequest::decl(), server::routes::relay_auth::StartSpake2EnrollmentResponse::decl(), server::routes::relay_auth::FinishSpake2EnrollmentResponse::decl(), server::routes::relay_auth::RelayPairedClient::decl(), server::routes::relay_auth::ListRelayPairedClientsResponse::decl(), server::routes::relay_auth::RemoveRelayPairedClientResponse::decl(), server::routes::relay_auth::RefreshRelaySigningSessionRequest::decl(), server::routes::relay_auth::RefreshRelaySigningSessionResponse::decl(), server::routes::sessions::CreateFollowUpAttempt::decl(), server::routes::sessions::ResetProcessRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchResponse::decl(), server::routes::workspaces::repos::AddWorkspaceRepoRequest::decl(), server::routes::workspaces::repos::AddWorkspaceRepoResponse::decl(), server::routes::workspaces::git::MergeWorkspaceRequest::decl(), server::routes::workspaces::git::PushWorkspaceRequest::decl(), server::routes::workspaces::git::RenameBranchRequest::decl(), server::routes::workspaces::git::RenameBranchResponse::decl(), server::routes::sessions::review::StartReviewRequest::decl(), server::routes::sessions::review::ReviewError::decl(), server::routes::workspaces::integration::OpenEditorRequest::decl(), server::routes::workspaces::integration::OpenEditorResponse::decl(), db::models::requests::LinkedIssueInfo::decl(), server::routes::workspaces::pr::CreatePrApiRequest::decl(), server::routes::attachments::AttachmentResponse::decl(), server::routes::attachments::AttachmentMetadata::decl(), db::models::requests::WorkspaceRepoInput::decl(), server::routes::workspaces::integration::RunAgentSetupRequest::decl(), server::routes::workspaces::integration::RunAgentSetupResponse::decl(), server::routes::workspaces::gh_cli_setup::GhCliSetupError::decl(), server::routes::workspaces::git::RebaseWorkspaceRequest::decl(), server::routes::workspaces::git::ContinueRebaseRequest::decl(), server::routes::workspaces::git::AbortConflictsRequest::decl(), server::routes::workspaces::git::GitOperationError::decl(), server::routes::workspaces::git::PushError::decl(), server::routes::workspaces::pr::PrError::decl(), server::routes::workspaces::execution::RunScriptError::decl(), server::routes::workspaces::attachments::AssociateWorkspaceAttachmentsRequest::decl(), server::routes::workspaces::attachments::ImportIssueAttachmentsRequest::decl(), server::routes::workspaces::attachments::ImportIssueAttachmentsResponse::decl(), server::routes::workspaces::pr::AttachPrResponse::decl(), server::routes::workspaces::pr::AttachExistingPrRequest::decl(), server::routes::workspaces::pr::PrCommentsResponse::decl(), server::routes::workspaces::pr::GetPrCommentsError::decl(), server::routes::workspaces::pr::GetPrCommentsQuery::decl(), db::models::requests::CreateAndStartWorkspaceRequest::decl(), db::models::requests::CreateAndStartWorkspaceResponse::decl(), git_host::UnifiedPrComment::decl(), git_host::ProviderKind::decl(), git_host::OpenPrInfo::decl(), git::GitRemote::decl(), server::routes::repo::ListPrsError::decl(), server::routes::workspaces::pr::CreateWorkspaceFromPrBody::decl(), server::routes::workspaces::pr::CreateWorkspaceFromPrResponse::decl(), server::routes::workspaces::pr::CreateFromPrError::decl(), server::routes::workspaces::git::RepoBranchStatus::decl(), db::models::requests::UpdateWorkspace::decl(), db::models::requests::UpdateSession::decl(), server::routes::workspaces::workspace_summary::WorkspaceSummaryRequest::decl(), server::routes::workspaces::workspace_summary::WorkspaceSummary::decl(), server::routes::workspaces::workspace_summary::WorkspaceSummaryResponse::decl(), server::routes::workspaces::workspace_summary::DiffStats::decl(), services::services::filesystem::DirectoryEntry::decl(), services::services::filesystem::DirectoryListResponse::decl(), services::services::file_search::SearchMode::decl(), services::services::config::Config::decl(), services::services::config::NotificationConfig::decl(), services::services::config::ThemeMode::decl(), services::services::config::EditorConfig::decl(), services::services::config::EditorType::decl(), services::services::config::EditorOpenError::decl(), services::services::config::GitHubConfig::decl(), services::services::config::SoundFile::decl(), services::services::config::UiLanguage::decl(), services::services::config::ShowcaseState::decl(), services::services::config::SendMessageShortcut::decl(), git::GitBranch::decl(), services::services::queued_message::QueuedMessage::decl(), services::services::queued_message::QueueStatus::decl(), git::ConflictOp::decl(), executors::actions::ExecutorAction::decl(), executors::mcp_config::McpConfig::decl(), executors::actions::ExecutorActionType::decl(), executors::profile::ExecutorConfig::decl(), executors::actions::script::ScriptContext::decl(), executors::actions::script::ScriptRequest::decl(), executors::actions::script::ScriptRequestLanguage::decl(), executors::executors::BaseCodingAgent::decl(), executors::executors::CodingAgent::decl(), executors::executors::SlashCommandDescription::decl(), executors::executors::AvailabilityInfo::decl(), executors::command::CommandBuilder::decl(), executors::profile::ExecutorProfileId::decl(), executors::profile::ExecutorRecentModels::decl(), executors::profile::ExecutorProfile::decl(), executors::profile::ExecutorConfigs::decl(), executors::executors::BaseAgentCapability::decl(), executors::executors::claude::ClaudeEffort::decl(), executors::executors::claude::ClaudeCode::decl(), executors::executors::gemini::Gemini::decl(), executors::executors::amp::Amp::decl(), executors::executors::codex::Codex::decl(), executors::executors::codex::SandboxMode::decl(), executors::executors::codex::AskForApproval::decl(), executors::executors::codex::ReasoningEffort::decl(), executors::executors::codex::ReasoningSummary::decl(), executors::executors::codex::ReasoningSummaryFormat::decl(), executors::executors::cursor::CursorAgent::decl(), executors::executors::copilot::Copilot::decl(), executors::executors::opencode::Opencode::decl(), executors::executors::qwen::QwenCode::decl(), executors::executors::droid::Droid::decl(), executors::executors::droid::Autonomy::decl(), executors::executors::droid::ReasoningEffortLevel::decl(), executors::executors::AppendPrompt::decl(), executors::actions::coding_agent_initial::CodingAgentInitialRequest::decl(), executors::actions::coding_agent_follow_up::CodingAgentFollowUpRequest::decl(), executors::actions::review::ReviewRequest::decl(), executors::actions::review::RepoReviewContext::decl(), executors::logs::CommandExitStatus::decl(), executors::logs::CommandRunResult::decl(), executors::logs::utils::shell_command_parsing::CommandCategory::decl(), executors::logs::NormalizedEntry::decl(), executors::logs::NormalizedEntryType::decl(), executors::logs::TokenUsageInfo::decl(), executors::logs::FileChange::decl(), executors::logs::ActionType::decl(), executors::logs::AnsweredQuestion::decl(), executors::logs::AskUserQuestionItem::decl(), executors::logs::AskUserQuestionOption::decl(), executors::logs::TodoItem::decl(), executors::logs::NormalizedEntryError::decl(), executors::logs::ToolResult::decl(), executors::logs::ToolResultValueType::decl(), executors::logs::ToolStatus::decl(), executors::logs::utils::patch::PatchType::decl(), executors::model_selector::ModelInfo::decl(), executors::model_selector::ReasoningOption::decl(), executors::model_selector::ModelProvider::decl(), executors::model_selector::AgentInfo::decl(), executors::model_selector::PermissionPolicy::decl(), executors::model_selector::ModelSelectorConfig::decl(), executors::executor_discovery::ExecutorDiscoveredOptions::decl(), serde_json::Value::decl(), ]; let body = decls .into_iter() .map(|d| { let trimmed = d.trim_start(); if trimmed.starts_with("export") { d } else { format!("export {trimmed}") } }) .collect::>() .join("\n\n"); // Append exported constants let constants = format!( "export const DEFAULT_PR_DESCRIPTION_PROMPT = {};\n\nexport const DEFAULT_COMMIT_REMINDER_PROMPT = {};", serde_json::to_string(DEFAULT_PR_DESCRIPTION_PROMPT).unwrap(), serde_json::to_string(DEFAULT_COMMIT_REMINDER_PROMPT).unwrap() ); format!("{HEADER}\n\n{body}\n\n{constants}") } fn generate_json_schema() -> Result { // Draft-07, inline everything (no $defs) let mut settings = SchemaSettings::draft07(); settings.inline_subschemas = true; let generator: SchemaGenerator = settings.into_generator(); let schema: Schema = generator.into_root_schema_for::(); // Convert to JSON value to manipulate it let mut schema_value: serde_json::Value = serde_json::to_value(&schema)?; // Remove the title from root schema to prevent RJSF from creating an outer field container if let Some(obj) = schema_value.as_object_mut() { obj.remove("title"); } let formatted = serde_json::to_string_pretty(&schema_value)?; Ok(formatted) } fn generate_schemas() -> Result, serde_json::Error> { // // Generate schemas for all executor types println!("Generating JSON schemas…"); let schemas: HashMap<&str, String> = HashMap::from([ ( "amp", generate_json_schema::()?, ), ( "claude_code", generate_json_schema::()?, ), ( "gemini", generate_json_schema::()?, ), ( "codex", generate_json_schema::()?, ), ( "cursor_agent", generate_json_schema::()?, ), ( "opencode", generate_json_schema::()?, ), ( "qwen_code", generate_json_schema::()?, ), ( "copilot", generate_json_schema::()?, ), ( "droid", generate_json_schema::()?, ), ]); println!( "✅ JSON schemas generated. {} schemas created.", schemas.len() ); Ok(schemas) } fn write_schemas( schemas_path: &Path, schemas: HashMap<&str, String>, ) -> Result<(), Box> { fs::create_dir_all(schemas_path)?; for (name, content) in schemas { let schema_file = schemas_path.join(format!("{}.json", name)); fs::write(&schema_file, content)?; println!("✅ Generated schema: {}", schema_file.display()); } Ok(()) } fn schemas_up_to_date(schemas_path: &Path, schemas: &HashMap<&str, String>) -> bool { for (name, expected_content) in schemas { let schema_file = schemas_path.join(format!("{}.json", name)); let current_content = fs::read_to_string(&schema_file).unwrap_or_default(); if ¤t_content != expected_content { eprintln!("❌ Schema shared/schemas/{}.json is not up to date.", name); return false; } } true } fn main() { let args: Vec = env::args().collect(); let check_mode = args.iter().any(|arg| arg == "--check"); let shared_path = Path::new("shared"); println!("Generating TypeScript types…"); let generated_types = generate_types_content(); let schema_content = match generate_schemas() { Ok(s) => s, Err(e) => { eprintln!("❌ Failed to generate JSON schemas: {}", e); std::process::exit(1); } }; let types_path = shared_path.join("types.ts"); let schemas_path = shared_path.join("schemas"); if check_mode { // Check TypeScript types let current = fs::read_to_string(&types_path).unwrap_or_default(); let types_up_to_date = if current == generated_types { println!("✅ shared/types.ts is up to date."); true } else { eprintln!("❌ shared/types.ts is not up to date."); false }; // Check JSON schemas let schemas_up_to_date = schemas_up_to_date(&schemas_path, &schema_content); // Exit with appropriate code if types_up_to_date && schemas_up_to_date { std::process::exit(0); } else { eprintln!("Please run 'npm run generate-types' and commit the changes."); std::process::exit(1); } } else { fs::create_dir_all(shared_path).expect("cannot create shared"); fs::remove_file(&types_path).ok(); fs::remove_dir_all(&schemas_path).ok(); fs::write(&types_path, generated_types).expect("unable to write types.ts"); println!("✅ TypeScript types generated in shared/types.ts"); write_schemas(&schemas_path, schema_content).expect("unable to write schemas"); println!("✅ JSON schemas generated in shared/schemas/"); } } ================================================ FILE: crates/server/src/error.rs ================================================ use axum::{ Json, extract::multipart::MultipartError, http::StatusCode, response::{IntoResponse, Response}, }; use db::models::{ execution_process::ExecutionProcessError, repo::RepoError, scratch::ScratchError, session::SessionError, workspace::WorkspaceError, }; use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::{command::CommandBuildError, executors::ExecutorError}; use git::GitServiceError; use git_host::GitHostError; use git2::Error as Git2Error; use local_deployment::pty::PtyError; use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, file::FileError, migration::MigrationError, remote_client::RemoteClientError, repo::RepoError as RepoServiceError, }; use thiserror::Error; use trusted_key_auth::error::TrustedKeyAuthError; use utils::response::ApiResponse; use workspace_manager::WorkspaceError as WorkspaceManagerError; use worktree_manager::WorktreeError; #[derive(Debug, Error, ts_rs::TS)] #[ts(type = "string")] pub enum ApiError { #[error(transparent)] Repo(#[from] RepoError), #[error(transparent)] Workspace(#[from] WorkspaceError), #[error(transparent)] Session(#[from] SessionError), #[error(transparent)] ScratchError(#[from] ScratchError), #[error(transparent)] ExecutionProcess(#[from] ExecutionProcessError), #[error(transparent)] GitService(#[from] GitServiceError), #[error(transparent)] GitHost(#[from] GitHostError), #[error(transparent)] Deployment(#[from] DeploymentError), #[error(transparent)] Container(#[from] ContainerError), #[error(transparent)] Executor(#[from] ExecutorError), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Worktree(#[from] WorktreeError), #[error(transparent)] Config(#[from] ConfigError), #[error(transparent)] File(#[from] FileError), #[error("Multipart error: {0}")] Multipart(#[from] MultipartError), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error(transparent)] EditorOpen(#[from] EditorOpenError), #[error(transparent)] RemoteClient(#[from] RemoteClientError), #[error("Unauthorized")] Unauthorized, #[error("Bad request: {0}")] BadRequest(String), #[error("Conflict: {0}")] Conflict(String), #[error("Forbidden: {0}")] Forbidden(String), #[error("Too many requests: {0}")] TooManyRequests(String), #[error(transparent)] CommandBuilder(#[from] CommandBuildError), #[error(transparent)] Pty(#[from] PtyError), #[error(transparent)] Migration(#[from] MigrationError), } impl From<&'static str> for ApiError { fn from(msg: &'static str) -> Self { ApiError::BadRequest(msg.to_string()) } } impl From for ApiError { fn from(err: Git2Error) -> Self { ApiError::GitService(GitServiceError::from(err)) } } impl From for ApiError { fn from(_: RemoteClientNotConfigured) -> Self { ApiError::BadRequest("Remote client not configured".to_string()) } } impl From for ApiError { fn from(err: WorkspaceManagerError) -> Self { match err { WorkspaceManagerError::Database(err) => ApiError::Database(err), WorkspaceManagerError::Repo(err) => ApiError::Repo(err), WorkspaceManagerError::Worktree(err) => ApiError::Worktree(err), WorkspaceManagerError::GitService(err) => ApiError::GitService(err), WorkspaceManagerError::Io(err) => ApiError::Io(err), WorkspaceManagerError::WorkspaceNotFound => { ApiError::Workspace(WorkspaceError::WorkspaceNotFound) } WorkspaceManagerError::RepoAlreadyAttached => { ApiError::Conflict("Repository already attached to workspace".to_string()) } WorkspaceManagerError::BranchNotFound { repo_name, branch } => { ApiError::BadRequest(format!( "Branch '{}' does not exist in repository '{}'", branch, repo_name )) } WorkspaceManagerError::NoRepositories => { ApiError::BadRequest("Workspace has no repositories configured".to_string()) } WorkspaceManagerError::PartialCreation(msg) => ApiError::Conflict(msg), } } } struct ErrorInfo { status: StatusCode, error_type: &'static str, message: Option, } impl ErrorInfo { fn internal(error_type: &'static str) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, error_type, message: Some("An internal error occurred. Please try again.".into()), } } fn not_found(error_type: &'static str, msg: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, error_type, message: Some(msg.into()), } } fn bad_request(error_type: &'static str, msg: impl Into) -> Self { Self { status: StatusCode::BAD_REQUEST, error_type, message: Some(msg.into()), } } fn conflict(error_type: &'static str, msg: impl Into) -> Self { Self { status: StatusCode::CONFLICT, error_type, message: Some(msg.into()), } } fn with_status(status: StatusCode, error_type: &'static str, msg: impl Into) -> Self { Self { status, error_type, message: Some(msg.into()), } } } fn remote_client_error(err: &RemoteClientError) -> ErrorInfo { use services::services::remote_client::HandoffErrorCode; match err { RemoteClientError::Auth => ErrorInfo::with_status( StatusCode::UNAUTHORIZED, "RemoteClientError", "Unauthorized. Please sign in again.", ), RemoteClientError::Timeout => ErrorInfo::with_status( StatusCode::GATEWAY_TIMEOUT, "RemoteClientError", "Remote service timeout. Please try again.", ), RemoteClientError::TokenRefreshTimeout => ErrorInfo::with_status( StatusCode::UNAUTHORIZED, "RemoteClientError", "Remote service timeout during token refresh. Please sign in again.", ), RemoteClientError::Transport(_) => ErrorInfo::with_status( StatusCode::BAD_GATEWAY, "RemoteClientError", "Remote service unavailable. Please try again.", ), RemoteClientError::Http { status, body } => { let msg = if body.is_empty() { "Remote service error. Please try again.".into() } else { serde_json::from_str::(body) .ok() .and_then(|v| v.get("error")?.as_str().map(String::from)) .unwrap_or_else(|| body.clone()) }; ErrorInfo::with_status( StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY), "RemoteClientError", msg, ) } RemoteClientError::Token(_) => ErrorInfo::with_status( StatusCode::BAD_GATEWAY, "RemoteClientError", "Remote service returned an invalid access token. Please sign in again.", ), RemoteClientError::Storage(_) => ErrorInfo { status: StatusCode::INTERNAL_SERVER_ERROR, error_type: "RemoteClientError", message: Some("Failed to persist credentials locally. Please retry.".into()), }, RemoteClientError::Api(code) => { let (status, msg) = match code { HandoffErrorCode::NotFound => ( StatusCode::NOT_FOUND, "The requested resource was not found.", ), HandoffErrorCode::Expired => { (StatusCode::UNAUTHORIZED, "The link or token has expired.") } HandoffErrorCode::AccessDenied => (StatusCode::FORBIDDEN, "Access denied."), HandoffErrorCode::UnsupportedProvider => ( StatusCode::BAD_REQUEST, "Unsupported authentication provider.", ), HandoffErrorCode::InvalidReturnUrl => { (StatusCode::BAD_REQUEST, "Invalid return URL.") } HandoffErrorCode::InvalidChallenge => { (StatusCode::BAD_REQUEST, "Invalid authentication challenge.") } HandoffErrorCode::ProviderError => ( StatusCode::BAD_GATEWAY, "Authentication provider error. Please try again.", ), HandoffErrorCode::InternalError => ( StatusCode::BAD_GATEWAY, "Internal remote service error. Please try again.", ), HandoffErrorCode::Other(m) => { return ErrorInfo::bad_request( "RemoteClientError", format!("Authentication error: {}", m), ); } }; ErrorInfo::with_status(status, "RemoteClientError", msg) } RemoteClientError::Serde(_) => ErrorInfo::bad_request( "RemoteClientError", "Unexpected response from remote service.", ), RemoteClientError::Url(_) => { ErrorInfo::bad_request("RemoteClientError", "Remote service URL is invalid.") } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let info = match &self { ApiError::Repo(RepoError::Database(_)) => ErrorInfo::internal("RepoError"), ApiError::Repo(RepoError::NotFound) => { ErrorInfo::not_found("RepoError", "Repository not found.") } ApiError::Workspace(WorkspaceError::Database(_)) => { ErrorInfo::internal("WorkspaceError") } ApiError::Workspace(WorkspaceError::WorkspaceNotFound) => { ErrorInfo::not_found("WorkspaceError", "Workspace not found.") } ApiError::Workspace(WorkspaceError::ValidationError(msg)) => { ErrorInfo::bad_request("WorkspaceError", msg.clone()) } ApiError::Workspace(WorkspaceError::BranchNotFound(branch)) => { ErrorInfo::not_found("WorkspaceError", format!("Branch '{}' not found.", branch)) } ApiError::Session(SessionError::Database(_)) => ErrorInfo::internal("SessionError"), ApiError::Session(SessionError::NotFound) => { ErrorInfo::not_found("SessionError", "Session not found.") } ApiError::Session(SessionError::WorkspaceNotFound) => { ErrorInfo::not_found("SessionError", "Workspace not found.") } ApiError::Session(SessionError::ExecutorMismatch { expected, actual }) => { ErrorInfo::conflict( "SessionError", format!( "Executor mismatch: session uses {} but request specified {}.", expected, actual ), ) } ApiError::ScratchError(ScratchError::Database(_)) => { ErrorInfo::internal("ScratchError") } ApiError::ScratchError(ScratchError::Serde(_)) => { ErrorInfo::bad_request("ScratchError", "Invalid scratch data format.") } ApiError::ScratchError(ScratchError::TypeMismatch { expected, actual }) => { ErrorInfo::bad_request( "ScratchError", format!( "Scratch type mismatch: expected '{}' but got '{}'.", expected, actual ), ) } ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound) => { ErrorInfo::not_found("ExecutionProcessError", "Execution process not found.") } ApiError::ExecutionProcess(_) => ErrorInfo::internal("ExecutionProcessError"), ApiError::GitService(git::GitServiceError::MergeConflicts { message, .. }) => { ErrorInfo::conflict("GitServiceError", message.clone()) } ApiError::GitService(git::GitServiceError::RebaseInProgress) => ErrorInfo::conflict( "GitServiceError", "A rebase is already in progress. Resolve conflicts or abort the rebase, then retry.", ), ApiError::GitService(git::GitServiceError::BranchNotFound(branch)) => { ErrorInfo::not_found( "GitServiceError", format!( "Branch '{}' not found. Try changing the target branch.", branch ), ) } ApiError::GitService(git::GitServiceError::BranchesDiverged(msg)) => { ErrorInfo::conflict( "GitServiceError", format!( "{} Rebase onto the target branch first, then retry the merge.", msg ), ) } ApiError::GitService(git::GitServiceError::WorktreeDirty(branch, files)) => { ErrorInfo::conflict( "GitServiceError", format!( "Branch '{}' has uncommitted changes ({}). Commit or revert them before retrying.", branch, files ), ) } ApiError::GitService(git::GitServiceError::GitCLI(git::GitCliError::AuthFailed( msg, ))) => ErrorInfo::with_status( StatusCode::UNAUTHORIZED, "GitServiceError", format!( "{}. Check your git credentials or SSH keys and try again.", msg ), ), ApiError::GitService(e) => ErrorInfo::with_status( StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError", format!("Git operation failed: {}", e), ), ApiError::GitHost(_) => ErrorInfo::internal("GitHostError"), ApiError::File(FileError::TooLarge(size, max)) => ErrorInfo::with_status( StatusCode::PAYLOAD_TOO_LARGE, "FileTooLarge", format!( "This file is too large ({:.1} MB). Maximum file size is {:.1} MB.", *size as f64 / 1_048_576.0, *max as f64 / 1_048_576.0 ), ), ApiError::File(FileError::NotFound) => { ErrorInfo::not_found("FileNotFound", "File not found.") } ApiError::File(_) => ErrorInfo { status: StatusCode::INTERNAL_SERVER_ERROR, error_type: "FileError", message: Some("Failed to process file. Please try again.".into()), }, ApiError::EditorOpen(EditorOpenError::LaunchFailed { .. }) => { ErrorInfo::internal("EditorLaunchError") } ApiError::EditorOpen(_) => { ErrorInfo::bad_request("EditorOpenError", format!("{}", self)) } ApiError::RemoteClient(err) => remote_client_error(err), ApiError::Pty(PtyError::SessionNotFound(_)) => { ErrorInfo::not_found("PtyError", "PTY session not found.") } ApiError::Pty(PtyError::SessionClosed) => { ErrorInfo::with_status(StatusCode::GONE, "PtyError", "PTY session closed.") } ApiError::Pty(_) => ErrorInfo::internal("PtyError"), ApiError::Unauthorized => ErrorInfo::with_status( StatusCode::UNAUTHORIZED, "Unauthorized", "Unauthorized. Please sign in again.", ), ApiError::BadRequest(msg) => ErrorInfo::bad_request("BadRequest", msg.clone()), ApiError::Conflict(msg) => ErrorInfo::conflict("ConflictError", msg.clone()), ApiError::Forbidden(msg) => { ErrorInfo::with_status(StatusCode::FORBIDDEN, "ForbiddenError", msg.clone()) } ApiError::TooManyRequests(msg) => ErrorInfo::with_status( StatusCode::TOO_MANY_REQUESTS, "TooManyRequests", msg.clone(), ), ApiError::Multipart(_) => ErrorInfo::bad_request( "MultipartError", "Failed to upload file. Please ensure the file is valid and try again.", ), ApiError::Deployment(_) => ErrorInfo::internal("DeploymentError"), ApiError::Container(err) => match err { ContainerError::GitServiceError(_) => ErrorInfo::internal("ContainerError"), ContainerError::Workspace(WorkspaceError::WorkspaceNotFound) => { ErrorInfo::not_found("ContainerError", "Workspace not found.") } ContainerError::Workspace(WorkspaceError::ValidationError(msg)) => { ErrorInfo::bad_request("ContainerError", msg.clone()) } ContainerError::Workspace(WorkspaceError::BranchNotFound(branch)) => { ErrorInfo::not_found( "ContainerError", format!("Branch '{}' not found.", branch), ) } ContainerError::ExecutorError(e) => ErrorInfo::with_status( StatusCode::INTERNAL_SERVER_ERROR, "ContainerError", format!("Executor error: {e}"), ), _ => ErrorInfo::internal("ContainerError"), }, ApiError::Executor(_) => ErrorInfo::internal("ExecutorError"), ApiError::CommandBuilder(_) => ErrorInfo::internal("CommandBuildError"), ApiError::Database(_) => ErrorInfo::internal("DatabaseError"), ApiError::Worktree(_) => ErrorInfo::internal("WorktreeError"), ApiError::Config(_) => ErrorInfo::internal("ConfigError"), ApiError::Io(_) => ErrorInfo::internal("IoError"), ApiError::Migration(MigrationError::Database(_)) => { ErrorInfo::internal("MigrationError") } ApiError::Migration(MigrationError::MigrationState(_)) => { ErrorInfo::internal("MigrationError") } ApiError::Migration(MigrationError::Workspace(_)) => { ErrorInfo::internal("MigrationError") } ApiError::Migration(MigrationError::RemoteClient(err)) => remote_client_error(err), ApiError::Migration(MigrationError::NotAuthenticated) => ErrorInfo::with_status( StatusCode::UNAUTHORIZED, "MigrationError", "Not authenticated - please log in first.", ), ApiError::Migration(MigrationError::OrganizationNotFound) => { ErrorInfo::not_found("MigrationError", "Organization not found for user.") } ApiError::Migration(MigrationError::EntityNotFound { entity_type, id }) => { ErrorInfo::not_found( "MigrationError", format!("Entity not found: {} with id {}", entity_type, id), ) } ApiError::Migration(MigrationError::MigrationInProgress) => { ErrorInfo::conflict("MigrationError", "Migration already in progress.") } ApiError::Migration(MigrationError::StatusMappingFailed(status)) => { ErrorInfo::bad_request( "MigrationError", format!("Status mapping failed: unknown status '{}'", status), ) } ApiError::Migration(MigrationError::BrokenReferenceChain(msg)) => { ErrorInfo::bad_request("MigrationError", format!("Broken reference chain: {}", msg)) } ApiError::Migration(MigrationError::RemoteError(msg)) => ErrorInfo::with_status( StatusCode::BAD_GATEWAY, "MigrationError", format!("Remote error: {}", msg), ), }; // Log internal errors so they are visible in server output. if info.status.is_server_error() { tracing::error!( error_type = info.error_type, status = %info.status, error = ?self, "API request failed" ); } let message = info .message .unwrap_or_else(|| format!("{}: {}", info.error_type, self)); let response = ApiResponse::<()>::error(&message); (info.status, Json(response)).into_response() } } impl From for ApiError { fn from(err: TrustedKeyAuthError) -> Self { match err { TrustedKeyAuthError::Unauthorized => ApiError::Unauthorized, TrustedKeyAuthError::BadRequest(msg) => ApiError::BadRequest(msg), TrustedKeyAuthError::Forbidden(msg) => ApiError::Forbidden(msg), TrustedKeyAuthError::TooManyRequests(msg) => ApiError::TooManyRequests(msg), TrustedKeyAuthError::Io(e) => ApiError::Io(e), } } } impl From for ApiError { fn from(err: RepoServiceError) -> Self { match err { RepoServiceError::Database(db_err) => ApiError::Database(db_err), RepoServiceError::Io(io_err) => ApiError::Io(io_err), RepoServiceError::PathNotFound(path) => { ApiError::BadRequest(format!("Path does not exist: {}", path.display())) } RepoServiceError::PathNotDirectory(path) => { ApiError::BadRequest(format!("Path is not a directory: {}", path.display())) } RepoServiceError::NotGitRepository(path) => { ApiError::BadRequest(format!("Path is not a git repository: {}", path.display())) } RepoServiceError::NotFound => ApiError::BadRequest("Repository not found".to_string()), RepoServiceError::DirectoryAlreadyExists(path) => { ApiError::BadRequest(format!("Directory already exists: {}", path.display())) } RepoServiceError::Git(git_err) => { ApiError::BadRequest(format!("Git error: {}", git_err)) } RepoServiceError::InvalidFolderName(name) => { ApiError::BadRequest(format!("Invalid folder name: {}", name)) } } } } ================================================ FILE: crates/server/src/lib.rs ================================================ pub mod error; pub mod middleware; pub mod preview_proxy; pub mod routes; pub mod startup; pub mod tunnel; // #[cfg(feature = "cloud")] // type DeploymentImpl = vibe_kanban_cloud::deployment::CloudDeployment; // #[cfg(not(feature = "cloud"))] pub type DeploymentImpl = local_deployment::LocalDeployment; ================================================ FILE: crates/server/src/main.rs ================================================ use anyhow::{self, Error as AnyhowError}; use deployment::DeploymentError; use server::startup; use sqlx::Error as SqlxError; use strip_ansi_escapes::strip; use thiserror::Error; use tracing_subscriber::{EnvFilter, prelude::*}; use utils::{ port_file::write_port_file_with_proxy, sentry::{self as sentry_utils, SentrySource, sentry_layer}, }; #[derive(Debug, Error)] pub enum VibeKanbanError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Sqlx(#[from] SqlxError), #[error(transparent)] Deployment(#[from] DeploymentError), #[error(transparent)] Other(#[from] AnyhowError), } #[tokio::main] async fn main() -> Result<(), VibeKanbanError> { // Install rustls crypto provider before any TLS operations rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); sentry_utils::init_once(SentrySource::Backend); let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); let filter_string = format!( "warn,server={level},services={level},db={level},executors={level},deployment={level},local_deployment={level},utils={level},codex_core=off", level = log_level ); let env_filter = EnvFilter::try_new(filter_string).expect("Failed to create tracing filter"); tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer().with_filter(env_filter)) .with(sentry_layer()) .init(); let port = std::env::var("BACKEND_PORT") .or_else(|_| std::env::var("PORT")) .ok() .and_then(|s| { let cleaned = String::from_utf8(strip(s.as_bytes())).expect("UTF-8 after stripping ANSI"); cleaned.trim().parse::().ok() }) .unwrap_or_else(|| { tracing::info!("No PORT environment variable set, using port 0 for auto-assignment"); 0 }); let proxy_port = std::env::var("PREVIEW_PROXY_PORT") .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or(0); let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let handle = startup::start_with_bind(&format!("{host}:{port}"), &format!("{host}:{proxy_port}")) .await?; if let Err(e) = write_port_file_with_proxy(handle.port, Some(handle.proxy_port)).await { tracing::warn!("Failed to write port file: {}", e); } // Production only: open browser if !cfg!(debug_assertions) { tracing::info!("Opening browser..."); let url = handle.url(); tokio::spawn(async move { if let Err(e) = utils::browser::open_browser(&url).await { tracing::warn!( "Failed to open browser automatically: {e}. Please open {url} manually." ); } }); } // Cancel the server when a shutdown signal (Ctrl-C / SIGTERM) arrives. let shutdown_token = handle.shutdown_token(); tokio::spawn(async move { shutdown_signal().await; tracing::info!("Shutdown signal received"); shutdown_token.cancel(); }); handle.serve().await?; Ok(()) } async fn shutdown_signal() { // Always wait for Ctrl+C let ctrl_c = async { if let Err(e) = tokio::signal::ctrl_c().await { tracing::error!("Failed to install Ctrl+C handler: {e}"); } }; #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; // Try to install SIGTERM handler, but don't panic if it fails let terminate = async { if let Ok(mut sigterm) = signal(SignalKind::terminate()) { sigterm.recv().await; } else { tracing::error!("Failed to install SIGTERM handler"); // Fallback: never resolves std::future::pending::<()>().await; } }; tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } #[cfg(not(unix))] { // Only ctrl_c is available, so just await it ctrl_c.await; } } ================================================ FILE: crates/server/src/middleware/error_logging.rs ================================================ use axum::{ extract::{MatchedPath, OriginalUri, Request}, middleware::Next, response::Response, }; pub async fn log_server_errors(request: Request, next: Next) -> Response { let method = request.method().clone(); let uri = request .extensions() .get::() .map(|original| original.0.clone()) .unwrap_or_else(|| request.uri().clone()); let matched_path = request .extensions() .get::() .map(|matched| matched.as_str().to_owned()); let response = next.run(request).await; if response.status().is_server_error() { tracing::error!( method = %method, uri = %uri, matched_path = matched_path.as_deref().unwrap_or(""), status = %response.status(), "API request returned server error" ); } response } ================================================ FILE: crates/server/src/middleware/mod.rs ================================================ pub mod error_logging; pub mod model_loaders; pub mod origin; pub mod relay_request_signature; pub use error_logging::*; pub use model_loaders::*; pub use origin::*; pub use relay_request_signature::*; ================================================ FILE: crates/server/src/middleware/model_loaders.rs ================================================ use axum::{ extract::{Path, Request, State}, http::StatusCode, middleware::Next, response::Response, }; use db::models::{ execution_process::ExecutionProcess, session::Session, tag::Tag, workspace::Workspace, }; use deployment::Deployment; use uuid::Uuid; use crate::DeploymentImpl; pub async fn load_workspace_middleware( State(deployment): State, Path(workspace_id): Path, mut request: Request, next: Next, ) -> Result { // Load the Workspace from the database let workspace = match Workspace::find_by_id(&deployment.db().pool, workspace_id).await { Ok(Some(w)) => w, Ok(None) => { tracing::warn!("Workspace {} not found", workspace_id); return Err(StatusCode::NOT_FOUND); } Err(e) => { tracing::error!("Failed to fetch Workspace {}: {}", workspace_id, e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; // Insert the workspace into extensions request.extensions_mut().insert(workspace); // Continue on Ok(next.run(request).await) } pub async fn load_execution_process_middleware( State(deployment): State, Path(process_id): Path, mut request: Request, next: Next, ) -> Result { // Load the execution process from the database let execution_process = match ExecutionProcess::find_by_id(&deployment.db().pool, process_id).await { Ok(Some(process)) => process, Ok(None) => { tracing::warn!("ExecutionProcess {} not found", process_id); return Err(StatusCode::NOT_FOUND); } Err(e) => { tracing::error!("Failed to fetch execution process {}: {}", process_id, e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; // Inject the execution process into the request request.extensions_mut().insert(execution_process); // Continue to the next middleware/handler Ok(next.run(request).await) } // Middleware that loads and injects Tag based on the tag_id path parameter pub async fn load_tag_middleware( State(deployment): State, Path(tag_id): Path, request: axum::extract::Request, next: Next, ) -> Result { // Load the tag from the database let tag = match Tag::find_by_id(&deployment.db().pool, tag_id).await { Ok(Some(tag)) => tag, Ok(None) => { tracing::warn!("Tag {} not found", tag_id); return Err(StatusCode::NOT_FOUND); } Err(e) => { tracing::error!("Failed to fetch tag {}: {}", tag_id, e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; // Insert the tag as an extension let mut request = request; request.extensions_mut().insert(tag); // Continue with the next middleware/handler Ok(next.run(request).await) } pub async fn load_session_middleware( State(deployment): State, Path(session_id): Path, mut request: Request, next: Next, ) -> Result { let session = match Session::find_by_id(&deployment.db().pool, session_id).await { Ok(Some(session)) => session, Ok(None) => { tracing::warn!("Session {} not found", session_id); return Err(StatusCode::NOT_FOUND); } Err(e) => { tracing::error!("Failed to fetch session {}: {}", session_id, e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; request.extensions_mut().insert(session); Ok(next.run(request).await) } ================================================ FILE: crates/server/src/middleware/origin.rs ================================================ use std::{net::IpAddr, sync::OnceLock}; use axum::{ body::Body, extract::Request, http::{StatusCode, header}, response::Response, }; use url::Url; #[derive(Clone, Debug, Eq, PartialEq)] struct OriginKey { https: bool, host: String, port: u16, } impl OriginKey { fn from_origin(origin: &str) -> Option { let url = Url::parse(origin).ok()?; let https = match url.scheme() { "http" => false, "https" => true, _ => return None, }; let host = normalize_host(url.host_str()?); let port = url.port_or_known_default()?; Some(Self { https, host, port }) } fn from_host_header(host: &str, https: bool) -> Option { let authority: axum::http::uri::Authority = host.parse().ok()?; let host = normalize_host(authority.host()); let port = authority.port_u16().unwrap_or_else(|| default_port(https)); Some(Self { https, host, port }) } } #[allow(clippy::result_large_err)] pub fn validate_origin(req: &mut Request) -> Result<(), Response> { // Relay-proxied requests are authenticated through the relay's own session // system, so origin validation is not applicable. if is_relay_request(req) { return Ok(()); } let Some(origin) = get_origin_header(req) else { return Ok(()); }; if origin.eq_ignore_ascii_case("null") { return Err(forbidden()); } let host = get_host_header(req); // quick short-circuit same-origin check if host.is_some_and(|host| origin_matches_host(origin, host)) { return Ok(()); } let Some(origin_key) = OriginKey::from_origin(origin) else { return Err(forbidden()); }; if allowed_origins() .iter() .any(|allowed| allowed == &origin_key) { return Ok(()); } if let Some(host_key) = host.and_then(|host| OriginKey::from_host_header(host, origin_key.https)) && host_key == origin_key { return Ok(()); } Err(forbidden()) } fn get_origin_header(req: &Request) -> Option<&str> { get_header(req, header::ORIGIN) } fn get_host_header(req: &Request) -> Option<&str> { get_header(req, header::HOST) } fn get_header(req: &Request, name: header::HeaderName) -> Option<&str> { req.headers() .get(name) .and_then(|v| v.to_str().ok()) .map(str::trim) } fn is_relay_request(req: &Request) -> bool { req.headers() .get("x-vk-relayed") .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.trim() == "1") } fn forbidden() -> Response { Response::builder() .status(StatusCode::FORBIDDEN) .body(Body::empty()) .unwrap_or_else(|_| Response::new(Body::empty())) } fn origin_matches_host(origin: &str, host: &str) -> bool { origin .strip_prefix("http://") .or_else(|| origin.strip_prefix("https://")) .is_some_and(|rest| rest.eq_ignore_ascii_case(host)) } fn normalize_host(host: &str) -> String { let trimmed = host.trim().trim_start_matches('[').trim_end_matches(']'); let lower = trimmed.to_ascii_lowercase(); if lower == "localhost" { return "localhost".to_string(); } if let Ok(ip) = lower.parse::() { if ip.is_loopback() { return "localhost".to_string(); } return ip.to_string(); } lower } fn default_port(https: bool) -> u16 { if https { 443 } else { 80 } } fn allowed_origins() -> &'static Vec { static ALLOWED: OnceLock> = OnceLock::new(); ALLOWED.get_or_init(|| { let value = match std::env::var("VK_ALLOWED_ORIGINS") { Ok(value) => value, Err(_) => return Vec::new(), }; value .split(',') .filter_map(|origin| OriginKey::from_origin(origin.trim())) .collect() }) } #[cfg(test)] mod tests { use axum::http::{Request, header}; use super::*; fn make_request(origin: Option<&str>, host: Option<&str>) -> Request { let mut builder = Request::builder().uri("/test").method("GET"); if let Some(origin) = origin { builder = builder.header(header::ORIGIN, origin); } if let Some(host) = host { builder = builder.header(header::HOST, host); } builder.body(Body::empty()).unwrap() } fn is_forbidden(result: Result<(), Response>) -> bool { matches!(result, Err(resp) if resp.status() == StatusCode::FORBIDDEN) } #[test] fn no_origin_header_allows_request() { let mut req = make_request(None, Some("example.com")); assert!(validate_origin(&mut req).is_ok()); } #[test] fn null_origin_is_forbidden() { for null in ["null", "NULL", "Null"] { let mut req = make_request(Some(null), Some("example.com")); assert!(is_forbidden(validate_origin(&mut req))); } } #[test] fn same_origin_allows_request() { // HTTP, HTTPS, with port, case-insensitive let cases = [ ("http://example.com", "example.com"), ("https://example.com", "example.com"), ("http://example.com:8080", "example.com:8080"), ("http://EXAMPLE.COM", "example.com"), ]; for (origin, host) in cases { let mut req = make_request(Some(origin), Some(host)); assert!(validate_origin(&mut req).is_ok(), "{origin} vs {host}"); } } #[test] fn cross_origin_forbidden() { let cases = [ ("http://unknown.com", "example.com"), // different host ("http://example.com:8080", "example.com:80"), // different port ("ftp://example.com", "example.com"), // non-http scheme ("not-a-valid-url", "example.com"), // invalid URL ("http://example.com", ""), // missing host (invalid) ]; for (origin, host) in cases { let host_opt = if host.is_empty() { None } else { Some(host) }; let mut req = make_request(Some(origin), host_opt); assert!(is_forbidden(validate_origin(&mut req)), "{origin}"); } } #[test] fn loopback_addresses_normalized_and_equivalent() { // All loopback forms normalize to "localhost" assert_eq!( OriginKey::from_origin("http://localhost:3000") .unwrap() .host, "localhost" ); assert_eq!( OriginKey::from_origin("http://127.0.0.1:3000") .unwrap() .host, "localhost" ); assert_eq!( OriginKey::from_origin("http://[::1]:3000").unwrap().host, "localhost" ); // Cross-loopback requests should be allowed let mut req = make_request(Some("http://127.0.0.1:3000"), Some("[::1]:3000")); assert!(validate_origin(&mut req).is_ok()); } #[test] fn default_ports_handled_correctly() { assert_eq!( OriginKey::from_origin("http://example.com").unwrap().port, 80 ); assert_eq!( OriginKey::from_origin("https://example.com").unwrap().port, 443 ); // Explicit default port matches implicit let mut req = make_request(Some("http://example.com:80"), Some("example.com")); assert!(validate_origin(&mut req).is_ok()); } } ================================================ FILE: crates/server/src/middleware/relay_request_signature.rs ================================================ use std::time::{SystemTime, UNIX_EPOCH}; use axum::{ body::{Body, to_bytes}, extract::{OriginalUri, Request, State}, http::HeaderValue, middleware::Next, response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use deployment::Deployment; use sha2::{Digest, Sha256}; use url::form_urlencoded; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; const RELAY_HEADER: &str = "x-vk-relayed"; const SIGNING_SESSION_HEADER: &str = "x-vk-sig-session"; const TIMESTAMP_HEADER: &str = "x-vk-sig-ts"; const NONCE_HEADER: &str = "x-vk-sig-nonce"; const REQUEST_SIGNATURE_HEADER: &str = "x-vk-sig-signature"; const RESPONSE_TIMESTAMP_HEADER: &str = "x-vk-resp-ts"; const RESPONSE_NONCE_HEADER: &str = "x-vk-resp-nonce"; const RESPONSE_SIGNATURE_HEADER: &str = "x-vk-resp-signature"; #[derive(Clone, Debug)] pub struct RelayRequestSignatureContext { pub signing_session_id: Uuid, pub request_nonce: String, } #[derive(Debug)] struct RelayRequestSignatureInput { signing_session_id: Uuid, timestamp: i64, nonce: String, request_signature_b64: String, path_and_query: String, } pub async fn require_relay_request_signature( State(deployment): State, request: Request, next: Next, ) -> Result { if !is_relay_request(&request) { return Ok(next.run(request).await); } let signature_input = extract_relay_request_signature_input(&request)?; let method = request.method().as_str().to_string(); let (parts, body) = request.into_parts(); let body_bytes = to_bytes(body, usize::MAX) .await .map_err(|_| ApiError::Unauthorized)?; let message = build_request_message( signature_input.timestamp, &method, &signature_input.path_and_query, &signature_input.signing_session_id, &signature_input.nonce, &body_bytes, ); if let Err(error) = deployment .relay_signing() .verify_message( signature_input.signing_session_id, signature_input.timestamp, &signature_input.nonce, message.as_bytes(), &signature_input.request_signature_b64, ) .await { tracing::warn!( signing_session_id = %signature_input.signing_session_id, path = %signature_input.path_and_query, reason = %error.as_str(), "Rejecting relay request with invalid signature" ); return Err(ApiError::Unauthorized); } let mut request = Request::from_parts(parts, Body::from(body_bytes)); request .extensions_mut() .insert(RelayRequestSignatureContext { signing_session_id: signature_input.signing_session_id, request_nonce: signature_input.nonce, }); Ok(next.run(request).await) } pub async fn sign_relay_response( State(deployment): State, request: Request, next: Next, ) -> Result { if !is_relay_request(&request) { return Ok(next.run(request).await); } let signature_input = extract_relay_request_signature_input(&request)?; let response = next.run(request).await; let (mut parts, body) = response.into_parts(); let body_bytes = to_bytes(body, usize::MAX) .await .map_err(|_| ApiError::Unauthorized)?; let response_timestamp = unix_timestamp_now().map_err(|_| ApiError::Unauthorized)?; let response_nonce = Uuid::new_v4().simple().to_string(); let status = parts.status.as_u16(); let message = build_response_message( response_timestamp, status, &signature_input.path_and_query, &signature_input.signing_session_id, &signature_input.nonce, &response_nonce, &body_bytes, ); let response_signature = deployment .relay_signing() .sign_message(signature_input.signing_session_id, message.as_bytes()) .await .map_err(|error| { tracing::warn!( signing_session_id = %signature_input.signing_session_id, path = %signature_input.path_and_query, reason = %error.as_str(), "Failed to sign relay response" ); ApiError::Unauthorized })?; insert_header( &mut parts, RESPONSE_TIMESTAMP_HEADER, &response_timestamp.to_string(), ); insert_header(&mut parts, RESPONSE_NONCE_HEADER, &response_nonce); insert_header(&mut parts, RESPONSE_SIGNATURE_HEADER, &response_signature); Ok(Response::from_parts(parts, Body::from(body_bytes))) } fn build_request_message( timestamp: i64, method: &str, path_and_query: &str, signing_session_id: &Uuid, nonce: &str, body: &[u8], ) -> String { let body_hash = BASE64_STANDARD.encode(Sha256::digest(body)); format!("v1|{timestamp}|{method}|{path_and_query}|{signing_session_id}|{nonce}|{body_hash}") } fn build_response_message( timestamp: i64, status: u16, path_and_query: &str, signing_session_id: &Uuid, request_nonce: &str, response_nonce: &str, body: &[u8], ) -> String { let body_hash = BASE64_STANDARD.encode(Sha256::digest(body)); format!( "v1|{timestamp}|{status}|{path_and_query}|{signing_session_id}|{request_nonce}|{response_nonce}|{body_hash}" ) } fn relay_path_and_query(request: &Request) -> Result { let Some(original_uri) = request.extensions().get::() else { tracing::warn!("Rejecting relay request without OriginalUri extension"); return Err(ApiError::Unauthorized); }; Ok(original_uri .0 .path_and_query() .map(|path_and_query| path_and_query.as_str().to_string()) .unwrap_or_else(|| original_uri.0.path().to_string())) } fn extract_relay_request_signature_input( request: &Request, ) -> Result { if let Some(from_headers) = try_parse_signature_from_headers(request)? { return Ok(from_headers); } if let Some(from_query) = try_parse_signature_from_query(request)? { return Ok(from_query); } Err(ApiError::Unauthorized) } fn try_parse_signature_from_headers( request: &Request, ) -> Result, ApiError> { let signing_session = parse_header_optional::(request, SIGNING_SESSION_HEADER); let timestamp = parse_header_optional::(request, TIMESTAMP_HEADER); let nonce = parse_header_optional::(request, NONCE_HEADER); let request_signature = parse_header_optional::(request, REQUEST_SIGNATURE_HEADER); let any_present = signing_session.is_some() || timestamp.is_some() || nonce.is_some() || request_signature.is_some(); let all_present = signing_session.is_some() && timestamp.is_some() && nonce.is_some() && request_signature.is_some(); if any_present && !all_present { return Err(ApiError::Unauthorized); } if !all_present { return Ok(None); } let signing_session_id = signing_session .and_then(|value| value.parse::().ok()) .ok_or(ApiError::Unauthorized)?; let timestamp = timestamp .and_then(|value| value.parse::().ok()) .ok_or(ApiError::Unauthorized)?; let nonce = nonce.ok_or(ApiError::Unauthorized)?; let request_signature_b64 = request_signature.ok_or(ApiError::Unauthorized)?; Ok(Some(RelayRequestSignatureInput { signing_session_id, timestamp, nonce, request_signature_b64, path_and_query: relay_path_and_query(request)?, })) } fn try_parse_signature_from_query( request: &Request, ) -> Result, ApiError> { let Some(original_uri) = request.extensions().get::() else { tracing::warn!("Rejecting relay request without OriginalUri extension"); return Err(ApiError::Unauthorized); }; let path = original_uri.0.path().to_string(); let query = original_uri.0.query().unwrap_or_default(); if query.is_empty() { return Ok(None); } let mut filtered_query = form_urlencoded::Serializer::new(String::new()); let mut signing_session: Option = None; let mut timestamp: Option = None; let mut nonce: Option = None; let mut request_signature: Option = None; for (key, value) in form_urlencoded::parse(query.as_bytes()) { match key.as_ref() { SIGNING_SESSION_HEADER => signing_session = Some(value.into_owned()), TIMESTAMP_HEADER => timestamp = Some(value.into_owned()), NONCE_HEADER => nonce = Some(value.into_owned()), REQUEST_SIGNATURE_HEADER => request_signature = Some(value.into_owned()), _ => { filtered_query.append_pair(&key, &value); } } } let any_present = signing_session.is_some() || timestamp.is_some() || nonce.is_some() || request_signature.is_some(); let all_present = signing_session.is_some() && timestamp.is_some() && nonce.is_some() && request_signature.is_some(); if any_present && !all_present { return Err(ApiError::Unauthorized); } if !any_present { return Ok(None); } let signing_session_id = signing_session .and_then(|value| value.parse::().ok()) .ok_or(ApiError::Unauthorized)?; let timestamp = timestamp .and_then(|value| value.parse::().ok()) .ok_or(ApiError::Unauthorized)?; let nonce = nonce.ok_or(ApiError::Unauthorized)?; let request_signature_b64 = request_signature.ok_or(ApiError::Unauthorized)?; let filtered = filtered_query.finish(); let path_and_query = if filtered.is_empty() { path } else { format!("{path}?{filtered}") }; Ok(Some(RelayRequestSignatureInput { signing_session_id, timestamp, nonce, request_signature_b64, path_and_query, })) } fn parse_header_optional(request: &Request, name: &'static str) -> Option { request .headers() .get(name) .and_then(|v| v.to_str().ok()) .and_then(|value| value.parse::().ok()) } fn insert_header(parts: &mut axum::http::response::Parts, name: &'static str, value: &str) { if let Ok(value) = HeaderValue::from_str(value) { parts.headers.insert(name, value); } } fn unix_timestamp_now() -> Result { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| ())?; i64::try_from(duration.as_secs()).map_err(|_| ()) } fn is_relay_request(request: &Request) -> bool { request .headers() .get(RELAY_HEADER) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.trim() == "1") } ================================================ FILE: crates/server/src/preview_proxy/bippy_bundle.js ================================================ var VKBippy=(()=>{var se=Object.defineProperty;var ut=Object.getOwnPropertyDescriptor;var ct=Object.getOwnPropertyNames;var ft=Object.prototype.hasOwnProperty;var mt=(e,t)=>{for(var n in t)se(e,n,{get:t[n],enumerable:!0})},pt=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of ct(t))!ft.call(e,a)&&a!==n&&se(e,a,{get:()=>t[a],enumerable:!(r=ut(t,a))||r.enumerable});return e};var dt=e=>pt(se({},"__esModule",{value:!0}),e);var ir={};mt(ir,{getDisplayName:()=>L,getFiberFromHostInstance:()=>Re,getOwnerStack:()=>Xe,isCompositeFiber:()=>ge,isInstrumentationActive:()=>ve,isSourceFile:()=>et,normalizeFileName:()=>Fe,traverseFiber:()=>V});var De="0.5.28",J=`bippy-${De}`,ke=Object.defineProperty,bt=Object.prototype.hasOwnProperty,U=()=>{},Ae=e=>{try{Function.prototype.toString.call(e).indexOf("^_^")>-1&&setTimeout(()=>{throw Error("React is running in production mode, but dead code elimination has not been applied. Read how to correctly configure React for production: https://reactjs.org/link/perf-use-production-build")})}catch{}},X=(e=D())=>"getFiberRoots"in e,He=!1,Ie,Y=(e=D())=>He?!0:(typeof e.inject=="function"&&(Ie=e.inject.toString()),!!Ie?.includes("(injected)")),Z=new Set,j=new Set,Me=e=>{let t=new Map,n=0,r={_instrumentationIsActive:!1,_instrumentationSource:J,checkDCE:Ae,hasUnsupportedRendererAttached:!1,inject(a){let s=++n;return t.set(s,a),j.add(a),r._instrumentationIsActive||(r._instrumentationIsActive=!0,Z.forEach(u=>u())),s},on:U,onCommitFiberRoot:U,onCommitFiberUnmount:U,onPostCommitFiberRoot:U,renderers:t,supportsFiber:!0,supportsFlight:!0};try{ke(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__",{configurable:!0,enumerable:!0,get(){return r},set(u){if(u&&typeof u=="object"){let d=r.renderers;r=u,d.size>0&&(d.forEach((f,m)=>{j.add(f),u.renderers.set(m,f)}),Q(e))}}});let a=window.hasOwnProperty,s=!1;ke(window,"hasOwnProperty",{configurable:!0,value:function(...u){try{if(!s&&u[0]==="__REACT_DEVTOOLS_GLOBAL_HOOK__")return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__=void 0,s=!0,-0}catch{}return a.apply(this,u)},writable:!0})}catch{Q(e)}return r},Q=e=>{e&&Z.add(e);try{let t=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!t)return;if(!t._instrumentationSource){t.checkDCE=Ae,t.supportsFiber=!0,t.supportsFlight=!0,t.hasUnsupportedRendererAttached=!1,t._instrumentationSource=J,t._instrumentationIsActive=!1;let n=X(t);if(n||(t.on=U),t.renderers.size){t._instrumentationIsActive=!0,Z.forEach(s=>s());return}let r=t.inject,a=Y(t);a&&!n&&(He=!0,t.inject({scheduleRefresh(){}})&&(t._instrumentationIsActive=!0)),t.inject=s=>{let u=r(s);return j.add(s),a&&t.renderers.set(u,s),t._instrumentationIsActive=!0,Z.forEach(d=>d()),u}}(t.renderers.size||t._instrumentationIsActive||Y())&&e?.()}catch{}},ie=()=>bt.call(globalThis,"__REACT_DEVTOOLS_GLOBAL_HOOK__"),D=e=>ie()?(Q(e),globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__):Me(e),je=()=>!!(typeof window<"u"&&(window.document?.createElement||window.navigator?.product==="ReactNative")),G=()=>{try{je()&&D()}catch{}};G();var ee=0,te=1;var le=5;var re=11,ue=13,Le=14,ne=15,ce=16;var fe=19;var me=26,pe=27,de=28,be=30;var gt=2,ht=4096,yt=4;var Tt=16,vt=32,Rt=1024,St=8192,mr=gt|yt|Tt|vt|ht|St|Rt;var ge=e=>{switch(e.tag){case te:case re:case ee:case Le:case ne:return!0;default:return!1}};function V(e,t,n=!1){if(!e)return null;let r=t(e);if(r instanceof Promise)return(async()=>{if(await r===!0)return e;let s=n?e.return:e.child;for(;s;){let u=await ye(s,t,n);if(u)return u;s=n?null:s.sibling}return null})();if(r===!0)return e;let a=n?e.return:e.child;for(;a;){let s=he(a,t,n);if(s)return s;a=n?null:a.sibling}return null}var he=(e,t,n=!1)=>{if(!e)return null;if(t(e)===!0)return e;let r=n?e.return:e.child;for(;r;){let a=he(r,t,n);if(a)return a;r=n?null:r.sibling}return null},ye=async(e,t,n=!1)=>{if(!e)return null;if(await t(e)===!0)return e;let r=n?e.return:e.child;for(;r;){let a=await ye(r,t,n);if(a)return a;r=n?null:r.sibling}return null};var Te=e=>{let t=e;return typeof t=="function"?t:typeof t=="object"&&t?Te(t.type||t.render):null},L=e=>{let t=e;if(typeof t=="string")return t;if(typeof t!="function"&&!(typeof t=="object"&&t))return null;let n=t.displayName||t.name||null;if(n)return n;let r=Te(t);return r&&(r.displayName||r.name)||null};var ve=()=>!!D()._instrumentationIsActive||X()||Y();var Re=e=>{let t=D();for(let n of t.renderers.values())try{let r=n.findFiberByHostInstance?.(e);if(r)return r}catch{}if(typeof e=="object"&&e){if("_reactRootContainer"in e)return e._reactRootContainer?._internalRoot?.current?.child;for(let n in e)if(n.startsWith("__reactContainer$")||n.startsWith("__reactInternalInstance$")||n.startsWith("__reactFiber"))return e[n]||null}return null},Nt=Error();var Ct=Object.create,Ge=Object.defineProperty,Ft=Object.getOwnPropertyDescriptor,_t=Object.getOwnPropertyNames,Ot=Object.getPrototypeOf,wt=Object.prototype.hasOwnProperty,Et=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),kt=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(var a=_t(t),s=0,u=a.length,d;st[f]).bind(null,d),enumerable:!(r=Ft(t,d))||r.enumerable});return e},It=(e,t,n)=>(n=e==null?{}:Ct(Ot(e)),kt(t||!e||!e.__esModule?Ge(n,"default",{value:e,enumerable:!0}):n,e)),xe=/^[a-zA-Z][a-zA-Z\d+\-.]*:/,Dt=["rsc://","file:///","webpack://","webpack-internal://","node:","turbopack://","metro://","/app-pages-browser/"],$e="about://React/",At=["","eval",""],Ht=/\.(jsx|tsx|ts|js)$/,Mt=/(\.min|bundle|chunk|vendor|vendors|runtime|polyfill|polyfills)\.(js|mjs|cjs)$|(chunk|bundle|vendor|vendors|runtime|polyfill|polyfills|framework|app|main|index)[-_.][A-Za-z0-9_-]{4,}\.(js|mjs|cjs)$|[\da-f]{8,}\.(js|mjs|cjs)$|[-_.][\da-f]{20,}\.(js|mjs|cjs)$|\/dist\/|\/build\/|\/.next\/|\/out\/|\/node_modules\/|\.webpack\.|\.vite\.|\.turbopack\./i,jt=/^\?[\w~.\-]+(?:=[^&#]*)?(?:&[\w~.\-]+(?:=[^&#]*)?)*$/,Ve="(at Server)",Lt=/(^|@)\S+:\d+/,We=/^\s*at .*(\S+:\d+|\(native\))/m,xt=/^(eval@)?(\[native code\])?$/;var Ke=(e,t)=>{if(t?.includeInElement!==!1){let n=e.split(` `),r=[];for(let a of n)if(/^\s*at\s+/.test(a)){let s=Pe(a,void 0)[0];s&&r.push(s)}else if(/^\s*in\s+/.test(a)){let s=a.replace(/^\s*in\s+/,"").replace(/\s*\(at .*\)$/,"");r.push({functionName:s,source:a})}else if(a.match(Lt)){let s=ze(a,void 0)[0];s&&r.push(s)}return Ce(r,t)}return e.match(We)?Pe(e,t):ze(e,t)},qe=e=>{if(!e.includes(":"))return[e,void 0,void 0];let t=e.startsWith("(")&&/:\d+\)$/.test(e),n=t?e.slice(1,-1):e,r=/(.+?)(?::(\d+))?(?::(\d+))?$/,a=r.exec(n);return a?[a[1],a[2]||void 0,a[3]||void 0]:[n,void 0,void 0]},Ce=(e,t)=>t&&t.slice!=null?Array.isArray(t.slice)?e.slice(t.slice[0],t.slice[1]):e.slice(0,t.slice):e;var Pe=(e,t)=>Ce(e.split(` `).filter(r=>!!r.match(We)),t).map(r=>{let a=r;a.includes("(eval ")&&(a=a.replace(/eval code/g,"eval").replace(/(\(eval at [^()]*)|(,.*$)/g,""));let s=a.replace(/^\s+/,"").replace(/\(eval code/g,"(").replace(/^.*?\s+/,""),u=s.match(/ (\(.+\)$)/);s=u?s.replace(u[0],""):s;let d=qe(u?u[1]:s),f=u&&s||void 0,m=["eval",""].includes(d[0])?void 0:d[0];return{functionName:f,fileName:m,lineNumber:d[1]?+d[1]:void 0,columnNumber:d[2]?+d[2]:void 0,source:a}});var ze=(e,t)=>Ce(e.split(` `).filter(r=>!r.match(xt)),t).map(r=>{let a=r;if(a.includes(" > eval")&&(a=a.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,":$1")),!a.includes("@")&&!a.includes(":"))return{functionName:a};{let s=/(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/,u=a.match(s),d=u&&u[1]?u[1]:void 0,f=qe(a.replace(s,""));return{functionName:d,fileName:f[0],lineNumber:f[1]?+f[1]:void 0,columnNumber:f[2]?+f[2]:void 0,source:a}}});var $t=Et((e,t)=>{(function(n,r){typeof e=="object"&&t!==void 0?r(e):typeof define=="function"&&define.amd?define(["exports"],r):(n=typeof globalThis<"u"?globalThis:n||self,r(n.sourcemapCodec={}))})(void 0,function(n){"use strict";let r=44,a=59,s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u=new Uint8Array(64),d=new Uint8Array(128);for(let c=0;c>>=1,b&&(o=-2147483648|-o),i+o}function m(c,i,o){let l=i-o;l=l<0?-l<<1|1:l<<1;do{let p=l&31;l>>>=5,l>0&&(p|=32),c.write(u[p])}while(l>0);return i}function _(c,i){return c.pos>=i?!1:c.peek()!==r}let H=1024*16,C=typeof TextDecoder<"u"?new TextDecoder:typeof Buffer<"u"?{decode(c){return Buffer.from(c.buffer,c.byteOffset,c.byteLength).toString()}}:{decode(c){let i="";for(let o=0;o0?o+C.decode(i.subarray(0,l)):o}}class y{constructor(i){this.pos=0,this.buffer=i}next(){return this.buffer.charCodeAt(this.pos++)}peek(){return this.buffer.charCodeAt(this.pos)}indexOf(i){let{buffer:o,pos:l}=this,p=o.indexOf(i,l);return p===-1?o.length:p}}let F=[];function x(c){let{length:i}=c,o=new y(c),l=[],p=[],b=0;for(;o.pos0&&o.write(r),l[0]=m(o,b,l[0]),m(o,R,0),m(o,S,0);let O=p.length===6?1:0;m(o,O,0),p.length===6&&m(o,p[5],0);for(let w of v)m(o,w,0);for(i++;iT||g===T&&E>=N)break;i=_e(c,i,o,l)}return o.write(r),l[0]=m(o,T,l[0]),m(o,N,0),i}function tt(c){let{length:i}=c,o=new y(c),l=[],p=[],b=0,R=0,T=0,N=0,S=0,v=0,O=0,w=0;do{let g=o.indexOf(";"),E=0;for(;o.posk;B--){let it=O;O=f(o,O),w=f(o,O===it?w:0);let lt=f(o,0);$.push([lt,O,w])}}else $=[[k]];oe.push($)}while(_(o,g))}M.bindings=oe,l.push(M),p.push(M)}b++,o.pos=g+1}while(o.pos0&&o.write(r),l[1]=m(o,p[1],l[1]);let w=(p.length===6?1:0)|(v?2:0)|(S?4:0);if(m(o,w,0),p.length===6){let{4:g,5:E}=p;g!==l[2]&&(l[3]=0),l[2]=m(o,g,l[2]),l[3]=m(o,E,l[3])}if(v){let{0:g,1:E,2:I}=p.callsite;g===l[4]?E!==l[5]&&(l[6]=0):(l[5]=0,l[6]=0),l[4]=m(o,g,l[4]),l[5]=m(o,E,l[5]),l[6]=m(o,I,l[6])}if(O)for(let g of O){g.length>1&&m(o,-g.length,0);let E=g[0][0];m(o,E,0);let I=b,q=R;for(let P=1;PT||E===T&&I>=N)break;i=Oe(c,i,o,l)}return l[0]0&&i.write(a),T.length===0)continue;let N=0;for(let S=0;S0&&i.write(r),N=m(i,v[0],N),v.length!==1&&(o=m(i,v[1],o),l=m(i,v[2],l),p=m(i,v[3],p),v.length!==4&&(b=m(i,v[4],b)))}}return i.flush()}n.decode=nt,n.decodeGeneratedRanges=tt,n.decodeOriginalScopes=x,n.encode=st,n.encodeGeneratedRanges=rt,n.encodeOriginalScopes=K,Object.defineProperty(n,"__esModule",{value:!0})})}),Ze=It($t(),1),Qe=/^[a-zA-Z][a-zA-Z\d+\-.]*:/,Pt=/^data:application\/json[^,]+base64,/,zt=/(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/,Je=typeof WeakRef<"u",W=new Map,ae=new Map,Bt=e=>Je&&e instanceof WeakRef,Be=(e,t,n,r)=>{if(n<0||n>=e.length)return null;let a=e[n];if(!a||a.length===0)return null;let s=null;for(let _ of a)if(_[0]<=r)s=_;else break;if(!s||s.length<4)return null;let[,u,d,f]=s;if(u===void 0||d===void 0||f===void 0)return null;let m=t[u];return m?{columnNumber:f,fileName:m,lineNumber:d+1}:null},Ut=(e,t,n)=>{if(e.sections){let r=null;for(let u of e.sections)if(t>u.offset.line||t===u.offset.line&&n>=u.offset.column)r=u;else break;if(!r)return null;let a=t-r.offset.line,s=t===r.offset.line?n-r.offset.column:n;return Be(r.map.mappings,r.map.sources,a,s)}return Be(e.mappings,e.sources,t-1,n)},Yt=(e,t)=>{let n=t.split(` `),r;for(let s=n.length-1;s>=0&&!r;s--){let u=n[s].match(zt);u&&(r=u[1]||u[2])}if(!r)return null;let a=Qe.test(r);if(!(Pt.test(r)||a||r.startsWith("/"))){let s=e.split("/");s[s.length-1]=r,r=s.join("/")}return r},Gt=e=>({file:e.file,mappings:(0,Ze.decode)(e.mappings),names:e.names,sourceRoot:e.sourceRoot,sources:e.sources,sourcesContent:e.sourcesContent,version:3}),Vt=e=>{let t=e.sections.map(({map:r,offset:a})=>({map:{...r,mappings:(0,Ze.decode)(r.mappings)},offset:a})),n=new Set;for(let r of t)for(let a of r.map.sources)n.add(a);return{file:e.file,mappings:[],names:[],sections:t,sourceRoot:void 0,sources:Array.from(n),sourcesContent:void 0,version:3}},Ue=e=>{if(!e)return!1;let t=e.trim();if(!t)return!1;let n=t.match(Qe);if(!n)return!0;let r=n[0].toLowerCase();return r==="http:"||r==="https:"},Wt=async(e,t=fetch)=>{if(!Ue(e))return null;let n;try{let a=await t(e);if(!a.ok)return null;n=await a.text()}catch{return null}if(!n)return null;let r=Yt(e,n);if(!r||!Ue(r))return null;try{let a=await t(r);if(!a.ok)return null;let s=await a.json();return"sections"in s?Vt(s):Gt(s)}catch{return null}},Kt=async(e,t=!0,n)=>{if(t&&W.has(e)){let s=W.get(e);if(s==null)return null;if(Bt(s)){let u=s.deref();if(u)return u;W.delete(e)}else return s}if(t&&ae.has(e))return ae.get(e);let r=Wt(e,n);t&&ae.set(e,r);let a=await r;return t&&ae.delete(e),t&&(a===null?W.set(e,null):W.set(e,Je?new WeakRef(a):a)),a},qt=async(e,t=!0,n)=>await Promise.all(e.map(async r=>{if(!r.fileName)return r;let a=await Kt(r.fileName,t,n);if(!a||typeof r.lineNumber!="number"||typeof r.columnNumber!="number")return r;let s=Ut(a,r.lineNumber,r.columnNumber);return s?{...r,source:s.fileName&&r.source?r.source.replace(r.fileName,s.fileName):r.source,fileName:s.fileName,lineNumber:s.lineNumber,columnNumber:s.columnNumber,isSymbolicated:!0}:r})),Zt=e=>e._debugStack instanceof Error&&typeof e._debugStack?.stack=="string",Qt=()=>{let e=D();for(let t of[...Array.from(j),...Array.from(e.renderers.values())]){let n=t.currentDispatcherRef;if(n&&typeof n=="object")return"H"in n?n.H:n.current}return null},Ye=e=>{for(let t of j){let n=t.currentDispatcherRef;n&&typeof n=="object"&&("H"in n?n.H=e:n.current=e)}},A=e=>` in ${e}`,Jt=(e,t)=>{let n=A(e);return t&&(n+=` (at ${t})`),n},Se=!1,Ne=(e,t)=>{if(!e||Se)return"";let n=Error.prepareStackTrace;Error.prepareStackTrace=void 0,Se=!0;let r=Qt();Ye(null);let a=console.error,s=console.warn;console.error=()=>{},console.warn=()=>{};try{let f={DetermineComponentFrameRoot(){let C;try{if(t){let h=function(){throw Error()};if(Object.defineProperty(h.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(h,[])}catch(y){C=y}Reflect.construct(e,[],h)}else{try{h.call()}catch(y){C=y}e.call(h.prototype)}}else{try{throw Error()}catch(y){C=y}let h=e();h&&typeof h.catch=="function"&&h.catch(()=>{})}}catch(h){if(h instanceof Error&&C instanceof Error&&typeof h.stack=="string")return[h.stack,C.stack]}return[null,null]}};f.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot",Object.getOwnPropertyDescriptor(f.DetermineComponentFrameRoot,"name")?.configurable&&Object.defineProperty(f.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});let[_,H]=f.DetermineComponentFrameRoot();if(_&&H){let C=_.split(` `),h=H.split(` `),y=0,F=0;for(;y=1&&F>=0&&C[y]!==h[F];)F--;for(;y>=1&&F>=0;y--,F--)if(C[y]!==h[F]){if(y!==1||F!==1)do if(y--,F--,F<0||C[y]!==h[F]){let x=` ${C[y].replace(" at new "," at ")}`,K=L(e);return K&&x.includes("")&&(x=x.replace("",K)),x}while(y>=1&&F>=0);break}}}finally{Se=!1,Error.prepareStackTrace=n,Ye(r),console.error=a,console.warn=s}let u=e?L(e):"";return u?A(u):""},Xt=(e,t)=>{let n=e.tag,r="";switch(n){case de:r=A("Activity");break;case te:r=Ne(e.type,!0);break;case re:r=Ne(e.type.render,!1);break;case ee:case ne:r=Ne(e.type,!1);break;case le:case me:case pe:r=A(e.type);break;case ce:r=A("Lazy");break;case ue:r=e.child!==t&&t!==null?A("Suspense Fallback"):A("Suspense");break;case fe:r=A("SuspenseList");break;case be:r=A("ViewTransition");break;default:return""}return r},er=e=>{try{let t="",n=e,r=null;do{t+=Xt(n,r);let a=n._debugInfo;if(a&&Array.isArray(a))for(let s=a.length-1;s>=0;s--){let u=a[s];typeof u.name=="string"&&(t+=Jt(u.name,u.env))}r=n,n=n.return}while(n);return t}catch(t){return t instanceof Error?` Error generating stack: ${t.message} ${t.stack}`:""}},tr=e=>{let t=Error.prepareStackTrace;Error.prepareStackTrace=void 0;let n=e;if(!n)return"";Error.prepareStackTrace=t,n.startsWith(`Error: react-stack-top-frame `)&&(n=n.slice(29));let r=n.indexOf(` `);if(r!==-1&&(n=n.slice(r+1)),r=Math.max(n.indexOf("react_stack_bottom_frame"),n.indexOf("react-stack-bottom-frame")),r!==-1&&(r=n.lastIndexOf(` `,r)),r!==-1)n=n.slice(0,r);else return"";return n},rr=e=>!!(e.fileName?.startsWith("rsc://")&&e.functionName),nr=(e,t)=>e.fileName===t.fileName&&e.lineNumber===t.lineNumber&&e.columnNumber===t.columnNumber,ar=e=>{let t=new Map;for(let n of e)for(let r of n.stackFrames){if(!rr(r))continue;let a=r.functionName,s=t.get(a)??[];s.some(d=>nr(d,r))||(s.push(r),t.set(a,s))}return t},or=(e,t,n)=>{if(!e.functionName)return{...e,isServer:!0};let r=t.get(e.functionName);if(!r||r.length===0)return{...e,isServer:!0};let a=n.get(e.functionName)??0,s=r[a%r.length];return n.set(e.functionName,a+1),{...e,isServer:!0,fileName:s.fileName,lineNumber:s.lineNumber,columnNumber:s.columnNumber,source:e.source?.replace(Ve,`(${s.fileName}:${s.lineNumber}:${s.columnNumber})`)}},sr=e=>{let t=[];return V(e,n=>{if(!Zt(n))return;let r=typeof n.type=="string"?n.type:L(n.type)||"";t.push({componentName:r,stackFrames:Ke(tr(n._debugStack?.stack))})},!0),t},Xe=async(e,t=!0,n)=>{let r=sr(e),a=Ke(er(e)),s=ar(r),u=new Map,d=a.map(m=>m.source?.includes(Ve)??!1?or(m,s,u):m),f=d.filter((m,_,H)=>{if(_===0)return!0;let C=H[_-1];return m.functionName!==C.functionName});return qt(f,t,n)};var Fe=e=>{if(!e||At.some(a=>a===e))return"";let t=e;if(t.startsWith("http://")||t.startsWith("https://"))try{t=new URL(t).pathname}catch{}if(t.startsWith($e)){let a=t.slice($e.length),s=a.indexOf("/"),u=a.indexOf(":");t=s!==-1&&(u===-1||s{let t=Fe(e);return!(!t||!Ht.test(t)||Mt.test(t))};G();return dt(ir);})(); /*! Bundled license information: bippy/dist/rdt-hook-5L_ky0r0.js: bippy/dist/install-hook-only-CgvoC7AQ.js: bippy/dist/core-U1d648PH.js: bippy/dist/index.js: bippy/dist/source.js: (** * @license bippy * * Copyright (c) Aiden Bai * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. *) */ ================================================ FILE: crates/server/src/preview_proxy/click_to_component_script.js ================================================ (function() { 'use strict'; // ============================================================================= // === CORE: State & Utilities === // ============================================================================= var SOURCE = 'click-to-component'; var inspectModeActive = false; var overlay = null; var nameLabel = null; var lastHoveredElement = null; // --- Helper: send message to parent --- function send(type, payload, version) { try { var msg = { source: SOURCE, type: type, payload: payload }; if (version) msg.version = version; window.parent.postMessage(msg, '*'); } catch(e) {} } // --- Helper: truncate attribute value --- function truncateAttr(val) { return val.length > 50 ? val.slice(0, 50) + '...' : val; } // --- Helper: generate HTML preview of element --- function getHTMLPreview(element) { var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown'; var attrs = ''; if (element.attributes) { for (var i = 0; i < element.attributes.length; i++) { var attr = element.attributes[i]; attrs += ' ' + attr.name + '="' + truncateAttr(attr.value) + '"'; } } var text = ''; if (element.innerText) { text = element.innerText.trim(); if (text.length > 100) text = text.slice(0, 100) + '...'; } if (text) { return '<' + tagName + attrs + '>\n ' + text + '\n'; } return '<' + tagName + attrs + ' />'; } // ============================================================================= // === ADAPTER INTERFACE === // ============================================================================= // // Each adapter implements: // { // name: string, // detect: function(element: HTMLElement) -> boolean, // getComponentInfo: function(element: HTMLElement) -> Promise, // getOverlayLabel?: function(element: HTMLElement) -> string | null // } // // ComponentPayload: // { // framework: string, // component: string, // tagName?: string, // file?: string, // line?: number, // column?: number, // cssClass?: string, // stack?: Array<{ name: string, file?: string }>, // htmlPreview: string // } // // The dispatcher iterates adapters in order. First adapter where detect() // returns true gets getComponentInfo() called. If it returns null, the // HTML fallback is used. // ============================================================================= // === REACT ADAPTER === // ============================================================================= // Internal component name lists to filter out var NEXT_INTERNAL = ['InnerLayoutRouter', 'RedirectErrorBoundary', 'RedirectBoundary', 'HTTPAccessFallbackErrorBoundary', 'HTTPAccessFallbackBoundary', 'LoadingBoundary', 'ErrorBoundary', 'InnerScrollAndFocusHandler', 'ScrollAndFocusHandler', 'RenderFromTemplateContext', 'OuterLayoutRouter', 'body', 'html', 'DevRootHTTPAccessFallbackBoundary', 'AppDevOverlayErrorBoundary', 'AppDevOverlay', 'HotReload', 'Router', 'ErrorBoundaryHandler', 'AppRouter', 'ServerRoot', 'SegmentStateProvider', 'RootErrorBoundary', 'LoadableComponent', 'MotionDOMComponent']; var REACT_INTERNAL = ['Suspense', 'Fragment', 'StrictMode', 'Profiler', 'SuspenseList']; function isSourceComponentName(name) { if (!name || name.length <= 1) return false; if (name.charAt(0) === '_') return false; if (NEXT_INTERNAL.indexOf(name) !== -1) return false; if (REACT_INTERNAL.indexOf(name) !== -1) return false; if (name.charAt(0) !== name.charAt(0).toUpperCase()) return false; if (name.indexOf('Primitive.') === 0) return false; if (name.indexOf('Provider') !== -1 && name.indexOf('Context') !== -1) return false; return true; } function isUsefulComponentName(name) { if (!name) return false; if (name.charAt(0) === '_') return false; if (NEXT_INTERNAL.indexOf(name) !== -1) return false; if (REACT_INTERNAL.indexOf(name) !== -1) return false; if (name.indexOf('Primitive.') === 0) return false; if (name === 'SlotClone' || name === 'Slot') return false; return true; } // --- Check if owner stack has source files --- function hasSourceFiles(stack) { if (!stack) return false; for (var i = 0; i < stack.length; i++) { if (stack[i].isServer) return true; if (stack[i].fileName && typeof VKBippy !== 'undefined' && VKBippy.isSourceFile(stack[i].fileName)) return true; } return false; } // --- Build ComponentPayload stack entries from owner stack --- function buildStackEntries(stack, maxLines) { var entries = []; var count = 0; for (var i = 0; i < stack.length && count < maxLines; i++) { var frame = stack[i]; if (frame.isServer) { entries.push({ name: frame.functionName || '', file: 'Server' }); count++; continue; } if (frame.fileName && typeof VKBippy !== 'undefined' && VKBippy.isSourceFile(frame.fileName)) { var name = ''; var file = VKBippy.normalizeFileName(frame.fileName); if (frame.lineNumber && frame.columnNumber) { file += ':' + frame.lineNumber + ':' + frame.columnNumber; } if (frame.functionName && isSourceComponentName(frame.functionName)) { name = frame.functionName; } entries.push({ name: name, file: file }); count++; } } return entries; } // --- Get component names by walking fiber tree --- function getComponentNamesFromFiber(element, maxCount) { var fiber = VKBippy.getFiberFromHostInstance(element); if (!fiber) return []; var names = []; VKBippy.traverseFiber(fiber, function(f) { if (names.length >= maxCount) return true; if (VKBippy.isCompositeFiber(f)) { var name = VKBippy.getDisplayName(f.type); if (name && isUsefulComponentName(name)) names.push(name); } return false; }, true); // goUp = true return names; } // --- Get nearest component display name (for overlay label) --- function getNearestComponentName(element) { if (typeof VKBippy === 'undefined' || !VKBippy.isInstrumentationActive()) return null; var fiber = VKBippy.getFiberFromHostInstance(element); if (!fiber) return null; var current = fiber.return; while (current) { if (VKBippy.isCompositeFiber(current)) { var name = VKBippy.getDisplayName(current.type); if (name && isUsefulComponentName(name)) return name; } current = current.return; } return null; } var reactAdapter = { name: 'react', detect: function(element) { return typeof VKBippy !== 'undefined' && VKBippy.isInstrumentationActive() && !!VKBippy.getFiberFromHostInstance(element); }, getComponentInfo: function(element) { var fiber = VKBippy.getFiberFromHostInstance(element); if (!fiber) return Promise.resolve(null); var htmlPreview = getHTMLPreview(element); var componentName = getNearestComponentName(element) || element.tagName.toLowerCase(); return VKBippy.getOwnerStack(fiber).then(function(stack) { if (hasSourceFiles(stack)) { var payload = { framework: 'react', component: componentName, htmlPreview: htmlPreview, stack: buildStackEntries(stack, 3) }; try { for (var i = 0; i < stack.length; i++) { var frame = stack[i]; if (!frame.isServer && frame.fileName && VKBippy.isSourceFile(frame.fileName)) { payload.file = VKBippy.normalizeFileName(frame.fileName); if (frame.lineNumber != null) payload.line = frame.lineNumber; if (frame.columnNumber != null) payload.column = frame.columnNumber; break; } } } catch(e) {} return payload; } // Fallback: component names without file paths var names = getComponentNamesFromFiber(element, 3); if (names.length > 0) { var stackEntries = []; for (var i = 0; i < names.length; i++) { stackEntries.push({ name: names[i] }); } return { framework: 'react', component: names[0], htmlPreview: htmlPreview, stack: stackEntries }; } return { framework: 'react', component: componentName, htmlPreview: htmlPreview }; }).catch(function() { // getOwnerStack failed - fall back to fiber walk var names = getComponentNamesFromFiber(element, 3); if (names.length > 0) { var stackEntries = []; for (var i = 0; i < names.length; i++) { stackEntries.push({ name: names[i] }); } return { framework: 'react', component: names[0], htmlPreview: htmlPreview, stack: stackEntries }; } return { framework: 'react', component: componentName, htmlPreview: htmlPreview }; }); }, getOverlayLabel: function(element) { return getNearestComponentName(element); } }; // ============================================================================= // === VUE ADAPTER === // ============================================================================= // --- Helper: extract component name from file path --- // e.g. '/src/components/AppHeader.vue' → 'AppHeader' function extractNameFromFile(filePath) { if (!filePath || typeof filePath !== 'string') return null; var parts = filePath.replace(/\\/g, '/').split('/'); var fileName = parts[parts.length - 1]; if (!fileName) return null; var dotIndex = fileName.lastIndexOf('.'); if (dotIndex > 0) return fileName.slice(0, dotIndex); return fileName; } // --- Helper: find Vue component instance from a DOM element --- // Walks up the DOM tree (max 50 ancestors) looking for __VUE__ or __vueParentComponent function findVueInstance(element) { var el = element; var depth = 0; while (el && depth < 50) { if (el.__VUE__ && el.__VUE__[0]) return el.__VUE__[0]; if (el.__vueParentComponent) return el.__vueParentComponent; el = el.parentElement; depth++; } return null; } // --- Helper: detect if element is inside a Vue 3 app --- function isVueElement(element) { // Check global hint first if (window.__VUE__) return true; // Walk up DOM looking for Vue markers var el = element; var depth = 0; while (el && depth < 50) { if (el.__VUE__ || el.__vueParentComponent) return true; el = el.parentElement; depth++; } return false; } // --- Helper: get Vue component name from instance with multi-level fallback --- function getVueComponentName(instance) { if (!instance || !instance.type) return 'Anonymous'; var type = instance.type; return type.displayName || type.name || type.__name || extractNameFromFile(type.__file) || 'Anonymous'; } // --- Helper: build component stack by walking instance.parent chain --- function buildVueComponentStack(instance, maxLevels) { var stack = []; var current = instance; var count = 0; while (current && count < maxLevels) { var name = getVueComponentName(current); if (name && name !== 'Anonymous') { var entry = { name: name }; if (current.type && current.type.__file) { entry.file = current.type.__file; } stack.push(entry); } current = current.parent; count++; } return stack; } var vueAdapter = { name: 'vue', detect: function(element) { return isVueElement(element); }, getComponentInfo: function(element) { var instance = findVueInstance(element); if (!instance) return Promise.resolve(null); var componentName = getVueComponentName(instance); var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown'; var cssClass = element.className ? String(element.className).split(' ')[0] : undefined; var filePath = (instance.type && instance.type.__file) ? instance.type.__file : undefined; var htmlPreview = getHTMLPreview(element); var stack = buildVueComponentStack(instance, 20); var payload = { framework: 'vue', component: componentName, tagName: tagName, htmlPreview: htmlPreview }; if (cssClass) payload.cssClass = cssClass; if (filePath) payload.file = filePath; if (stack.length > 0) payload.stack = stack; return Promise.resolve(payload); }, getOverlayLabel: function(element) { var instance = findVueInstance(element); if (!instance) return null; var name = getVueComponentName(instance); return (name && name !== 'Anonymous') ? name : null; } }; // ============================================================================= // === SVELTE ADAPTER === // ============================================================================= // --- Helper: find nearest element with __svelte_meta by walking up DOM --- function findSvelteMeta(element) { var el = element; var depth = 0; while (el && depth < 50) { if (el.__svelte_meta) return el; el = el.parentElement; depth++; } return null; } // --- Helper: check if element or ancestor has svelte-* CSS class (hint only) --- function hasSvelteClassHint(element) { var el = element; var depth = 0; while (el && depth < 50) { if (el.className && typeof el.className === 'string') { var classes = el.className.split(' '); for (var i = 0; i < classes.length; i++) { if (classes[i].indexOf('svelte-') === 0) return true; } } el = el.parentElement; depth++; } return false; } // --- Helper: extract component name from Svelte file path --- // e.g. 'src/routes/+page.svelte' → '+page', 'src/lib/Button.svelte' → 'Button' function extractSvelteComponentName(filePath) { if (!filePath || typeof filePath !== 'string') return null; var parts = filePath.replace(/\\/g, '/').split('/'); var fileName = parts[parts.length - 1]; if (!fileName) return null; var dotIndex = fileName.lastIndexOf('.'); if (dotIndex > 0) return fileName.slice(0, dotIndex); return fileName; } // --- Helper: get first non-svelte-hash CSS class --- function getFirstNonSvelteClass(element) { if (!element.className || typeof element.className !== 'string') return undefined; var classes = element.className.split(' '); for (var i = 0; i < classes.length; i++) { var cls = classes[i].trim(); if (cls && cls.indexOf('svelte-') !== 0) return cls; } return undefined; } var svelteAdapter = { name: 'svelte', detect: function(element) { // Check element and ancestors for __svelte_meta (max 50 depth) // Also check for svelte-* CSS class as a hint, but only return true // if __svelte_meta is actually found somewhere if (findSvelteMeta(element)) return true; // Svelte CSS class hint present but no __svelte_meta found — not enough return false; }, getComponentInfo: function(element) { var metaEl = findSvelteMeta(element); if (!metaEl || !metaEl.__svelte_meta) return Promise.resolve(null); var meta = metaEl.__svelte_meta; var loc = meta.loc; if (!loc || !loc.file) return Promise.resolve(null); var componentName = extractSvelteComponentName(loc.file) || 'Unknown'; var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown'; var cssClass = getFirstNonSvelteClass(element); var htmlPreview = getHTMLPreview(element); var fileLoc = loc.file; if (loc.line != null) fileLoc += ':' + loc.line; if (loc.column != null) fileLoc += ':' + loc.column; var payload = { framework: 'svelte', component: componentName, tagName: tagName, file: loc.file, line: loc.line, column: loc.column, htmlPreview: htmlPreview, stack: [{ name: componentName, file: fileLoc }] }; if (cssClass) payload.cssClass = cssClass; return Promise.resolve(payload); }, getOverlayLabel: function(element) { var metaEl = findSvelteMeta(element); if (!metaEl || !metaEl.__svelte_meta || !metaEl.__svelte_meta.loc) return null; return extractSvelteComponentName(metaEl.__svelte_meta.loc.file); } }; // ============================================================================= // === ASTRO ADAPTER === // ============================================================================= // --- Helper: extract component name from Astro component-url --- // e.g. '/src/components/Counter.jsx' → 'Counter' function extractAstroComponentName(componentUrl) { if (!componentUrl || typeof componentUrl !== 'string') return null; var clean = componentUrl.split('?')[0].split('#')[0]; var parts = clean.replace(/\\/g, '/').split('/'); var fileName = parts[parts.length - 1]; if (!fileName) return null; var dotIndex = fileName.lastIndexOf('.'); if (dotIndex > 0) return fileName.slice(0, dotIndex); return fileName; } // --- Helper: detect likely inner framework from renderer-url --- function detectInnerFramework(rendererUrl) { if (!rendererUrl || typeof rendererUrl !== 'string') return null; var url = rendererUrl.toLowerCase(); if (url.indexOf('react') !== -1 || url.indexOf('preact') !== -1) return 'react'; if (url.indexOf('vue') !== -1) return 'vue'; if (url.indexOf('svelte') !== -1) return 'svelte'; if (url.indexOf('solid') !== -1) return 'solid'; return null; } // --- Helper: attempt inner framework detection within an island --- // Tries adapters directly (not via the adapters array) to get inner component info. // Only tries frameworks hinted by renderer-url, falling back to trying all. function getInnerFrameworkInfo(element, island, rendererHint) { var candidates = []; if (rendererHint === 'react') { candidates.push(reactAdapter); } else if (rendererHint === 'vue') { candidates.push(vueAdapter); } else if (rendererHint === 'svelte') { candidates.push(svelteAdapter); } else { candidates.push(reactAdapter); candidates.push(vueAdapter); candidates.push(svelteAdapter); } var el = element; while (el && el !== island.parentElement) { for (var i = 0; i < candidates.length; i++) { if (candidates[i].detect(el)) { return candidates[i].getComponentInfo(el); } } el = el.parentElement; } return Promise.resolve(null); } var astroAdapter = { name: 'astro', detect: function(element) { return !!element.closest && !!element.closest('astro-island'); }, getComponentInfo: function(element) { var island = element.closest('astro-island'); if (!island) return Promise.resolve(null); var componentUrl = island.getAttribute('component-url') || ''; var componentExport = island.getAttribute('component-export') || 'default'; var rendererUrl = island.getAttribute('renderer-url') || ''; var clientDirective = island.getAttribute('client') || ''; var componentName = extractAstroComponentName(componentUrl) || 'AstroIsland'; var htmlPreview = getHTMLPreview(element); var rendererHint = detectInnerFramework(rendererUrl); return getInnerFrameworkInfo(element, island, rendererHint).then(function(innerPayload) { var stack = []; if (innerPayload) { if (innerPayload.stack) { for (var i = 0; i < innerPayload.stack.length; i++) { stack.push(innerPayload.stack[i]); } } else { var innerEntry = { name: innerPayload.component || 'Unknown' }; if (innerPayload.file) innerEntry.file = innerPayload.file; stack.push(innerEntry); } } var astroEntry = { name: componentName }; if (componentUrl) astroEntry.file = componentUrl; stack.push(astroEntry); var payload = { framework: 'astro', component: innerPayload ? innerPayload.component : componentName, htmlPreview: htmlPreview, stack: stack }; if (componentUrl) payload.file = componentUrl; return payload; }); }, getOverlayLabel: function(element) { var island = element.closest('astro-island'); if (!island) return null; var rendererUrl = island.getAttribute('renderer-url') || ''; var rendererHint = detectInnerFramework(rendererUrl); if (rendererHint === 'react' && reactAdapter.getOverlayLabel) { var reactLabel = reactAdapter.getOverlayLabel(element); if (reactLabel) return reactLabel; } if (rendererHint === 'vue' && vueAdapter.getOverlayLabel) { var vueLabel = vueAdapter.getOverlayLabel(element); if (vueLabel) return vueLabel; } if (rendererHint === 'svelte' && svelteAdapter.getOverlayLabel) { var svelteLabel = svelteAdapter.getOverlayLabel(element); if (svelteLabel) return svelteLabel; } var componentUrl = island.getAttribute('component-url'); return extractAstroComponentName(componentUrl) || null; } }; // ============================================================================= // === HTML FALLBACK === // ============================================================================= var htmlFallbackAdapter = { name: 'html-fallback', detect: function() { return true; }, getComponentInfo: function(element) { var tagName = element.tagName ? element.tagName.toLowerCase() : 'unknown'; var cssClass = element.className ? String(element.className).split(' ')[0] : undefined; return Promise.resolve({ framework: 'html', component: tagName, tagName: tagName, cssClass: cssClass, htmlPreview: getHTMLPreview(element) }); } }; // ============================================================================= // === ADAPTER REGISTRY & DISPATCHER === // ============================================================================= var adapters = [astroAdapter, reactAdapter, vueAdapter, svelteAdapter]; // --- Diagnostic: detect which frameworks are present on the page --- function detectFrameworks() { var detected = []; // Check for Astro islands if (document.querySelector('astro-island')) detected.push('astro'); // Check for React (VKBippy) if (typeof VKBippy !== 'undefined' && VKBippy.isInstrumentationActive && VKBippy.isInstrumentationActive()) detected.push('react'); // Check for Vue if (window.__VUE__ || document.querySelector('[data-v-app]')) detected.push('vue'); // Check for Svelte (check for svelte CSS classes) if (document.querySelector('[class*="svelte-"]') || document.querySelector('[data-svelte-h]')) detected.push('svelte'); return detected; } // --- Convert ComponentPayload to markdown string (v1 postMessage format) --- function payloadToMarkdown(payload) { var markdown = payload.htmlPreview; if (payload.stack) { for (var i = 0; i < payload.stack.length; i++) { var entry = payload.stack[i]; markdown += '\n in '; if (entry.name && entry.file) { markdown += entry.name + ' (at ' + entry.file + ')'; } else if (entry.file) { markdown += entry.file; } else if (entry.name) { markdown += entry.name; } } } return markdown; } // --- Dispatcher: iterate adapters, first match wins, fallback to HTML --- // Returns raw ComponentPayload (v2 protocol — no markdown conversion) function getElementContext(element) { for (var i = 0; i < adapters.length; i++) { if (adapters[i].detect(element)) { return adapters[i].getComponentInfo(element).then(function(payload) { if (payload) return payload; return htmlFallbackAdapter.getComponentInfo(element); }); } } return htmlFallbackAdapter.getComponentInfo(element); } // --- Get overlay label from first matching adapter --- function getOverlayLabelForElement(element) { for (var i = 0; i < adapters.length; i++) { if (adapters[i].getOverlayLabel) { var label = adapters[i].getOverlayLabel(element); if (label) return label; } } return null; } // ============================================================================= // === CORE: Overlay, Events & Initialization === // ============================================================================= function createOverlay() { if (overlay) return; overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:999999;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease;display:none;'; nameLabel = document.createElement('div'); nameLabel.style.cssText = 'position:absolute;top:-22px;left:0;background:#3b82f6;color:white;font-size:11px;padding:2px 6px;border-radius:3px;white-space:nowrap;font-family:system-ui,sans-serif;'; overlay.appendChild(nameLabel); document.body.appendChild(overlay); } function removeOverlay() { if (overlay && overlay.parentNode) { overlay.parentNode.removeChild(overlay); } overlay = null; nameLabel = null; } function positionOverlay(element) { if (!overlay) return; var rect = element.getBoundingClientRect(); overlay.style.display = 'block'; overlay.style.top = rect.top + 'px'; overlay.style.left = rect.left + 'px'; overlay.style.width = rect.width + 'px'; overlay.style.height = rect.height + 'px'; var compName = getOverlayLabelForElement(element); if (nameLabel) { nameLabel.textContent = compName || element.tagName.toLowerCase(); nameLabel.style.display = 'block'; } } function hideOverlay() { if (overlay) overlay.style.display = 'none'; } // --- Event handlers --- function onMouseOver(event) { if (!inspectModeActive) return; var el = event.target; if (el === overlay || (overlay && overlay.contains(el))) return; if (el === lastHoveredElement) return; lastHoveredElement = el; positionOverlay(el); } function onClick(event) { if (!inspectModeActive) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); var el = event.target; if (el === overlay || (overlay && overlay.contains(el))) return; // Exit inspect mode immediately (visual feedback) setInspectMode(false); getElementContext(el).then(function(componentPayload) { send('component-detected', componentPayload, 2); }); } // --- setInspectMode --- function setInspectMode(active) { if (active === inspectModeActive) return; inspectModeActive = active; if (active) { createOverlay(); document.body.style.cursor = 'crosshair'; document.addEventListener('mouseover', onMouseOver, true); document.addEventListener('click', onClick, true); } else { document.removeEventListener('mouseover', onMouseOver, true); document.removeEventListener('click', onClick, true); document.body.style.cursor = ''; hideOverlay(); removeOverlay(); lastHoveredElement = null; } } // --- Message listener --- window.addEventListener('message', function(event) { if (!event.data || event.data.source !== SOURCE) return; if (event.data.type === 'toggle-inspect') { setInspectMode(event.data.payload && event.data.payload.active); } }); // --- Log detected frameworks on page load (diagnostic only) --- if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { console.debug('[vk-ctc] Detected frameworks:', detectFrameworks().join(', ') || 'none'); }); } else { console.debug('[vk-ctc] Detected frameworks:', detectFrameworks().join(', ') || 'none'); } })(); ================================================ FILE: crates/server/src/preview_proxy/devtools_script.js ================================================ (function() { 'use strict'; var SOURCE = 'vibe-devtools'; var NAV_SESSION_POINTER_KEY = '__vk_nav_session'; var NAV_SESSION_PREFIX = '__vk_nav_'; var DOC_ID = Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); function send(type, payload) { try { window.parent.postMessage({ source: SOURCE, type: type, payload: payload }, '*'); } catch (e) { // Ignore if parent is not accessible } } function getNavStorageKey() { var sessionId = 'default'; try { var params = new URLSearchParams(location.search); var refresh = params.get('_refresh'); if (refresh) { sessionStorage.setItem(NAV_SESSION_POINTER_KEY, refresh); sessionId = refresh; } else { var saved = sessionStorage.getItem(NAV_SESSION_POINTER_KEY); if (saved) sessionId = saved; } } catch (e) { // sessionStorage may be unavailable } return NAV_SESSION_PREFIX + sessionId; } var NAV_STORAGE_KEY = getNavStorageKey(); var navStack = []; var navIndex = -1; var navSeq = 0; var lastObservedHref = location.href; var originalPushState = history.pushState; var originalReplaceState = history.replaceState; function normalizeUrl(url) { try { var u = new URL(url); u.searchParams.delete('_refresh'); return u.toString(); } catch (e) { return url; } } function loadNavState() { try { var saved = sessionStorage.getItem(NAV_STORAGE_KEY); if (!saved) return; var state = JSON.parse(saved); if (Array.isArray(state.stack)) { navStack = state.stack .map(function(entry) { if (typeof entry === 'string') return entry; if (entry && typeof entry.url === 'string') return entry.url; return null; }) .filter(function(entry) { return typeof entry === 'string' && entry.length > 0; }); } else { navStack = []; } navIndex = typeof state.index === 'number' ? state.index : -1; if (navIndex >= navStack.length) navIndex = navStack.length - 1; } catch (e) { navStack = []; navIndex = -1; } } function saveNavState() { try { sessionStorage.setItem( NAV_STORAGE_KEY, JSON.stringify({ stack: navStack, index: navIndex }) ); } catch (e) { // ignore storage errors } } function sendNavigation() { navSeq += 1; send('navigation', { docId: DOC_ID, seq: navSeq, url: location.href, title: document.title, canGoBack: navIndex > 0, canGoForward: navIndex < navStack.length - 1, timestamp: Date.now() }); } function ensureCurrentInStack(currentHref, mode) { var normalized = normalizeUrl(currentHref); var found = false; if (mode === 'replace') { if (navIndex >= 0 && navIndex < navStack.length) { navStack[navIndex] = currentHref; } else { navStack = [currentHref]; navIndex = 0; } return; } if (mode === 'push') { navStack = navStack.slice(0, navIndex + 1); navStack.push(currentHref); navIndex = navStack.length - 1; return; } if (navIndex >= 0 && navIndex < navStack.length && normalizeUrl(navStack[navIndex]) === normalized) { navStack[navIndex] = currentHref; found = true; } else if (navIndex + 1 < navStack.length && normalizeUrl(navStack[navIndex + 1]) === normalized) { navIndex++; navStack[navIndex] = currentHref; found = true; } else if (navIndex > 0 && normalizeUrl(navStack[navIndex - 1]) === normalized) { navIndex--; navStack[navIndex] = currentHref; found = true; } else { for (var i = 0; i < navStack.length; i++) { if (normalizeUrl(navStack[i]) === normalized) { navIndex = i; navStack[navIndex] = currentHref; found = true; break; } } } if (!found) { navStack = navStack.slice(0, navIndex + 1); navStack.push(currentHref); navIndex = navStack.length - 1; } } function observeLocation(mode) { var currentHref = location.href; lastObservedHref = currentHref; ensureCurrentInStack(currentHref, mode || 'auto'); saveNavState(); sendNavigation(); } function initializeNavigation() { loadNavState(); if (navStack.length === 0) { navStack = [location.href]; navIndex = 0; saveNavState(); } else if (navIndex < 0 || navIndex >= navStack.length) { navIndex = navStack.length - 1; if (navIndex < 0) navIndex = 0; saveNavState(); } observeLocation(); } window.addEventListener('popstate', function() { observeLocation('auto'); }); window.addEventListener('hashchange', function() { observeLocation('auto'); }); window.addEventListener('pageshow', function() { observeLocation('auto'); }); window.addEventListener('load', function() { observeLocation('auto'); }); history.pushState = function(state, title, url) { var result = originalPushState.apply(this, arguments); observeLocation('push'); return result; }; history.replaceState = function(state, title, url) { var result = originalReplaceState.apply(this, arguments); observeLocation('replace'); return result; }; window.addEventListener('message', function(event) { if (!event.data || event.data.source !== SOURCE || event.data.type !== 'navigate') { return; } var payload = event.data.payload; if (!payload) return; switch (payload.action) { case 'back': if (navIndex > 0) history.back(); break; case 'forward': if (navIndex < navStack.length - 1) history.forward(); break; case 'refresh': location.reload(); break; case 'goto': if (payload.url) { navStack = navStack.slice(0, navIndex + 1); navStack.push(payload.url); navIndex = navStack.length - 1; saveNavState(); sendNavigation(); location.href = payload.url; } break; } }); window.setInterval(function() { if (location.href !== lastObservedHref) { observeLocation('auto'); } }, 150); send('ready', { docId: DOC_ID }); initializeNavigation(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { observeLocation(); }); } else { observeLocation(); } })(); ================================================ FILE: crates/server/src/preview_proxy/eruda_init.js ================================================ (function() { 'use strict'; const SOURCE = 'vibe-devtools'; const COMMAND_SOURCE = 'vibe-kanban'; // === Helper: Send message to parent === function send(type, payload) { try { window.parent.postMessage({ source: SOURCE, type, payload }, '*'); } catch (e) { // Ignore if parent is not accessible } } // === Initialize Eruda === function initEruda() { if (typeof window.eruda === 'undefined') { // Eruda CDN failed to load, silently skip return; } // Initialize with dark theme window.eruda.init({ defaults: { theme: 'Dark' } }); window.eruda.hide(); try { var entryBtn = window.eruda._entryBtn; if (entryBtn && entryBtn._$el && entryBtn._$el[0]) { entryBtn._$el[0].style.display = 'none'; } } catch (e) { /* ignore */ } // Send ready signal send('eruda-ready', {}); } // === Command Receiver === window.addEventListener('message', function(event) { if (!event.data || event.data.source !== COMMAND_SOURCE) { return; } if (typeof window.eruda === 'undefined') { return; } var command = event.data.command; switch (command) { case 'toggle-eruda': if (window.eruda._isShow) { window.eruda.hide(); } else { window.eruda.show(); } break; case 'show-eruda': window.eruda.show(); break; case 'hide-eruda': window.eruda.hide(); break; } }); // === Initialize when ready === if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initEruda); } else { initEruda(); } })(); ================================================ FILE: crates/server/src/preview_proxy/mod.rs ================================================ //! Preview Proxy Server Module //! //! Provides a separate HTTP server for serving preview iframe content. //! This isolates preview content from the main application for security. //! //! The proxy listens on a separate port and routes requests based on the //! Host header subdomain. A request to `{port}.localhost:{proxy_port}/path` //! is forwarded to `localhost:{port}/path`. use std::sync::OnceLock; use axum::{ Router, body::Body, extract::{FromRequestParts, Request, ws::WebSocketUpgrade}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use futures_util::{SinkExt, StreamExt}; use reqwest::Client; use tokio_tungstenite::tungstenite::{self, client::IntoClientRequest}; use tower_http::validate_request::ValidateRequestHeaderLayer; /// Global storage for the preview proxy port once assigned. /// Set once during server startup, read by the config API. static PROXY_PORT: OnceLock = OnceLock::new(); /// Shared HTTP client for proxying requests. /// Reused across all requests to leverage connection pooling per upstream host:port. static HTTP_CLIENT: OnceLock = OnceLock::new(); /// Get or initialize the shared HTTP client. fn http_client() -> &'static Client { HTTP_CLIENT.get_or_init(|| { Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .expect("failed to build proxy HTTP client") }) } fn env_flag_enabled(name: &str) -> bool { std::env::var(name).is_ok_and(|value| { value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("on") }) } /// Get the preview proxy port if set. pub fn get_proxy_port() -> Option { PROXY_PORT.get().copied() } /// Set the preview proxy port. Can only be called once. /// Returns the port if successfully set, or None if already set. pub fn set_proxy_port(port: u16) -> Option { PROXY_PORT.set(port).ok().map(|()| port) } const SKIP_REQUEST_HEADERS: &[&str] = &[ "host", "connection", "transfer-encoding", "upgrade", "proxy-connection", "keep-alive", "te", "trailer", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "accept-encoding", "origin", ]; /// Headers that should be stripped from the proxied response. const STRIP_RESPONSE_HEADERS: &[&str] = &[ "content-security-policy", "content-security-policy-report-only", "x-frame-options", "x-content-type-options", "transfer-encoding", "connection", "content-encoding", ]; /// DevTools script injected before in HTML responses. /// Captures console, network, errors and sends via postMessage. const DEVTOOLS_SCRIPT: &str = include_str!("devtools_script.js"); /// Bippy bundle script injected after to install React DevTools hook /// before React initializes. Provides fiber inspection utilities. const BIPPY_BUNDLE: &str = include_str!("bippy_bundle.js"); /// Click-to-component detection script injected before . /// Enables inspect mode for detecting React component hierarchy. const CLICK_TO_COMPONENT_SCRIPT: &str = include_str!("click_to_component_script.js"); /// Eruda DevTools initialization script. Initializes Eruda with dark theme /// and listens for toggle commands from parent window. const ERUDA_INIT: &str = include_str!("eruda_init.js"); /// Collect response headers to forward to the iframe response. /// Keeps duplicate headers (e.g. `Set-Cookie`) by preserving each entry. fn collect_response_headers( upstream_headers: &HeaderMap, is_html: bool, ) -> Vec<(HeaderName, HeaderValue)> { let mut headers = Vec::new(); for (name, value) in upstream_headers { let name_lower = name.as_str().to_ascii_lowercase(); if STRIP_RESPONSE_HEADERS.contains(&name_lower.as_str()) { continue; } if is_html && name_lower == "content-length" { continue; } if let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) { headers.push((name.clone(), header_value)); } } headers } fn is_loopback_redirect_host(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "0.0.0.0" | "::1") } fn trim_wrapping_quotes(value: &str) -> &str { if value.len() < 2 { return value; } let bytes = value.as_bytes(); let first = bytes[0]; let last = bytes[value.len() - 1]; let has_matching_double = first == b'"' && last == b'"'; let has_matching_single = first == b'\'' && last == b'\''; if has_matching_double || has_matching_single { &value[1..value.len() - 1] } else { value } } fn trim_trailing_redirect_punctuation(mut value: &str) -> &str { loop { let trimmed = value.trim_end(); if trimmed.ends_with(',') || trimmed.ends_with(';') { value = trimmed[..trimmed.len() - 1].trim_end(); continue; } return trimmed; } } fn normalize_redirect_like_url_token(value: &str) -> Option { let mut candidate = value.trim(); if candidate.is_empty() { return None; } candidate = trim_trailing_redirect_punctuation(candidate); loop { let unquoted = trim_wrapping_quotes(candidate).trim(); if unquoted == candidate { break; } candidate = trim_trailing_redirect_punctuation(unquoted); } // We only rewrite plain URL tokens. Values containing spaces/quotes usually belong // to structured headers and must be left untouched. if candidate.is_empty() || candidate.chars().any(char::is_whitespace) || candidate.contains('"') || candidate.contains('\'') { return None; } Some(candidate.to_string()) } fn normalize_refresh_url_token(raw_value: &str) -> &str { let without_trailing_punctuation = trim_trailing_redirect_punctuation(raw_value.trim()); trim_wrapping_quotes(without_trailing_punctuation).trim() } fn rewrite_redirect_like_header_value( value: &str, target_port: u16, proxy_port: u16, ) -> Option { let original_value = value.trim(); if original_value.is_empty() { return None; } let normalized_value = normalize_redirect_like_url_token(original_value)?; // Relative redirects should stay relative so browser keeps current proxy origin. if (normalized_value.starts_with('/') && !normalized_value.starts_with("//")) || normalized_value.starts_with('?') || normalized_value.starts_with('#') { if normalized_value == original_value { return None; } return Some(normalized_value); } let mut parsed = if normalized_value.starts_with("//") { reqwest::Url::parse(&format!("http:{normalized_value}")).ok()? } else { reqwest::Url::parse(&normalized_value).ok()? }; let host = parsed.host_str()?.to_ascii_lowercase(); if !is_loopback_redirect_host(&host) { if normalized_value == original_value { return None; } return Some(normalized_value); } let parsed_port = parsed.port_or_known_default()?; if parsed_port != target_port { if normalized_value == original_value { return None; } return Some(normalized_value); } parsed.set_scheme("http").ok()?; parsed .set_host(Some(&format!("{target_port}.localhost"))) .ok()?; parsed.set_port(Some(proxy_port)).ok()?; Some(parsed.to_string()) } fn rewrite_refresh_header_value(value: &str, target_port: u16, proxy_port: u16) -> Option { let mut segments: Vec = value.split(';').map(|s| s.trim().to_string()).collect(); if segments.len() < 2 { return None; } for segment in segments.iter_mut().skip(1) { let segment_lower = segment.to_ascii_lowercase(); if !segment_lower.starts_with("url=") { continue; } let raw_value = segment[4..].trim(); let raw_unquoted = normalize_refresh_url_token(raw_value); if raw_unquoted.is_empty() { continue; } if let Some(rewritten) = rewrite_redirect_like_header_value(raw_unquoted, target_port, proxy_port) { *segment = format!("url={rewritten}"); return Some(segments.join("; ")); } } None } fn is_redirect_like_header_name(name_lower: &str) -> bool { name_lower == "location" || name_lower == "content-location" || name_lower == "refresh" || name_lower.contains("redirect") || name_lower.contains("rewrite") } fn rewrite_redirect_like_headers( headers: &mut [(HeaderName, HeaderValue)], target_port: u16, proxy_port: Option, ) { let Some(proxy_port) = proxy_port else { return; }; for (name, value) in headers.iter_mut() { let name_lower = name.as_str().to_ascii_lowercase(); if !is_redirect_like_header_name(&name_lower) { continue; } let Ok(value_str) = value.to_str() else { continue; }; let rewritten = if name_lower == "refresh" { rewrite_refresh_header_value(value_str, target_port, proxy_port) } else { rewrite_redirect_like_header_value(value_str, target_port, proxy_port) }; if let Some(rewritten) = rewritten && let Ok(rewritten_header) = HeaderValue::from_str(&rewritten) { *value = rewritten_header; } } } fn extract_target_from_host(headers: &HeaderMap) -> Option { let host = headers.get(header::HOST)?.to_str().ok()?; let subdomain = host.split('.').next()?; subdomain.parse::().ok() } async fn subdomain_proxy(request: Request) -> Response { let target_port = match extract_target_from_host(request.headers()) { Some(port) => port, None => { return (StatusCode::BAD_REQUEST, "No valid port in Host subdomain").into_response(); } }; let path = request.uri().path().trim_start_matches('/').to_string(); proxy_impl(target_port, path, request).await } async fn proxy_impl(target_port: u16, path_str: String, request: Request) -> Response { let (mut parts, body) = request.into_parts(); // Extract query string and subprotocols before WebSocket upgrade. // Both are required: Vite 6+ needs ?token= for auth, and checks // Sec-WebSocket-Protocol: vite-hmr before accepting the upgrade. let query_string = parts.uri.query().map(|q| q.to_string()); let ws_protocols: Option = parts .headers .get("sec-websocket-protocol") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); if let Ok(ws) = WebSocketUpgrade::from_request_parts(&mut parts, &()).await { tracing::debug!( "WebSocket upgrade request for path: {} -> localhost:{}", path_str, target_port ); let ws = if let Some(ref protocols) = ws_protocols { let protocol_list: Vec = protocols.split(',').map(|p| p.trim().to_string()).collect(); ws.protocols(protocol_list) } else { ws }; return ws .on_upgrade(move |client_socket| async move { if let Err(e) = handle_ws_proxy( client_socket, target_port, path_str, query_string, ws_protocols, ) .await { tracing::warn!("WebSocket proxy closed: {}", e); } }) .into_response(); } let request = Request::from_parts(parts, body); http_proxy_handler(target_port, path_str, request).await } async fn http_proxy_handler(target_port: u16, path_str: String, request: Request) -> Response { let (parts, body) = request.into_parts(); let method = parts.method; let headers = parts.headers; let original_uri = parts.uri; let query_string = original_uri.query().unwrap_or_default(); let target_url = if query_string.is_empty() { format!("http://localhost:{}/{}", target_port, path_str) } else { format!( "http://localhost:{}/{}?{}", target_port, path_str, query_string ) }; let is_rsc_request = headers.contains_key(header::HeaderName::from_static("rsc")); let is_get_request = method == axum::http::Method::GET; let client = http_client(); let mut req_builder = client.request( reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET), &target_url, ); for (name, value) in headers.iter() { let name_lower = name.as_str().to_ascii_lowercase(); if !SKIP_REQUEST_HEADERS.contains(&name_lower.as_str()) && let Ok(v) = value.to_str() { req_builder = req_builder.header(name.as_str(), v); } } if let Some(host) = headers.get(header::HOST) && let Ok(host_str) = host.to_str() { req_builder = req_builder.header("X-Forwarded-Host", host_str); } req_builder = req_builder.header("X-Forwarded-Proto", "http"); req_builder = req_builder.header("Accept-Encoding", "identity"); let forwarded_for = headers .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) .unwrap_or("127.0.0.1"); req_builder = req_builder.header("X-Forwarded-For", forwarded_for); let body_bytes = match axum::body::to_bytes(body, 50 * 1024 * 1024).await { Ok(b) => b, Err(e) => { tracing::error!("Failed to read request body: {}", e); return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response(); } }; if !body_bytes.is_empty() { req_builder = req_builder.body(body_bytes.to_vec()); } let response = match req_builder.send().await { Ok(r) => r, Err(e) => { tracing::error!("Failed to proxy request to {}: {}", target_url, e); return ( StatusCode::BAD_GATEWAY, format!("Dev server unreachable: {}", e), ) .into_response(); } }; let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); let is_html = content_type.contains("text/html"); let mut response_headers = collect_response_headers(response.headers(), is_html); rewrite_redirect_like_headers(&mut response_headers, target_port, get_proxy_port()); let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK); // RSC redirect interception — BEFORE is_html branch to catch all response types. // Response is 200 with x-nextjs-redirect header → convert to 307 so // the browser follows it natively (V1 approach, now in correct location). if is_get_request && is_rsc_request { // Scenario 2: 200 with x-nextjs-redirect — convert to 307 (V1 approach, now before is_html) if !status.is_redirection() { let rsc_redirect_target = response_headers .iter() .find(|(name, _)| name.as_str().eq_ignore_ascii_case("x-nextjs-redirect")) .and_then(|(_, value)| value.to_str().ok()) .map(|v| v.to_owned()); if let Some(ref redirect_target) = rsc_redirect_target { // Consume body before building new response let _ = response.bytes().await; let mut builder = Response::builder().status(StatusCode::TEMPORARY_REDIRECT); for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } if let Ok(location_value) = HeaderValue::from_str(redirect_target) { builder = builder.header(header::LOCATION, location_value); } return builder.body(Body::empty()).unwrap_or_else(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to build RSC redirect response", ) .into_response() }); } } } if is_html { match response.bytes().await { Ok(body_bytes) => { let mut html = String::from_utf8_lossy(&body_bytes).to_string(); // Inject bippy bundle after (must load before React) if let Some(pos) = html.to_lowercase().find("") { let head_end = pos + "".len(); let bippy_tag = format!("", BIPPY_BUNDLE); html.insert_str(head_end, &bippy_tag); } // Inject Eruda CDN, init, devtools and click-to-component scripts before if let Some(pos) = html.to_lowercase().rfind("") { let nav_script_disabled = env_flag_enabled("VK_PREVIEW_DISABLE_NAV_SCRIPT"); let scripts = if nav_script_disabled { format!( "", ERUDA_INIT, CLICK_TO_COMPONENT_SCRIPT ) } else { format!( "", ERUDA_INIT, DEVTOOLS_SCRIPT, CLICK_TO_COMPONENT_SCRIPT ) }; html.insert_str(pos, &scripts); } let mut builder = Response::builder().status(status); for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } builder.body(Body::from(html)).unwrap_or_else(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response", ) .into_response() }) } Err(e) => { tracing::error!("Failed to read HTML response: {}", e); ( StatusCode::BAD_GATEWAY, "Failed to read response from dev server", ) .into_response() } } } else { // x-nextjs-redirect header already handled above (Path A) // For RSC GET requests, read body to detect redirect encoded in flight data if is_get_request && is_rsc_request { let body_bytes = response.bytes().await.unwrap_or_default(); // V5: Detect redirect encoded in RSC flight data body if let Some(redirect_info) = detect_rsc_redirect_in_body(&body_bytes) { // Determine the final redirect URL let final_url = if redirect_info.url.starts_with("http://") || redirect_info.url.starts_with("https://") { // Absolute URL — rewrite to maintain proxy isolation if let Some(proxy_port) = get_proxy_port() { rewrite_redirect_like_header_value( &redirect_info.url, target_port, proxy_port, ) .unwrap_or_else(|| redirect_info.url.clone()) } else { redirect_info.url.clone() } } else { // Relative URL — use as-is (browser resolves against proxy origin) redirect_info.url.clone() }; // Build redirect response with the status from the digest let redirect_status = StatusCode::from_u16(redirect_info.status_code) .unwrap_or(StatusCode::TEMPORARY_REDIRECT); let mut builder = Response::builder().status(redirect_status); // Preserve all response headers (cookies, cache-control, etc.) for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } // Set Location header if let Ok(location_value) = HeaderValue::from_str(&final_url) { builder = builder.header(header::LOCATION, location_value); } return builder.body(Body::empty()).unwrap_or_else(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to build RSC flight redirect response", ) .into_response() }); } let mut builder = Response::builder().status(status); for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } builder.body(Body::from(body_bytes)).unwrap_or_else(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response", ) .into_response() }) } else { let stream = response.bytes_stream(); let body = Body::from_stream(stream); let mut builder = Response::builder().status(status); for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } builder.body(body).unwrap_or_else(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response", ) .into_response() }) } } } async fn handle_ws_proxy( client_socket: axum::extract::ws::WebSocket, target_port: u16, path: String, query_string: Option, ws_protocols: Option, ) -> anyhow::Result<()> { let ws_url = match &query_string { Some(q) if !q.is_empty() => { format!("ws://localhost:{}/{}?{}", target_port, path, q) } _ => format!("ws://localhost:{}/{}", target_port, path), }; tracing::debug!("Connecting to dev server WebSocket: {}", ws_url); let mut ws_request = ws_url.into_client_request()?; if let Some(ref protocols) = ws_protocols { ws_request .headers_mut() .insert("sec-websocket-protocol", protocols.parse()?); } let (dev_server_ws, _response) = tokio_tungstenite::connect_async(ws_request).await?; tracing::debug!("Connected to dev server WebSocket"); let (mut client_sender, mut client_receiver) = client_socket.split(); let (mut dev_sender, mut dev_receiver) = dev_server_ws.split(); let client_to_dev = tokio::spawn(async move { while let Some(msg_result) = client_receiver.next().await { match msg_result { Ok(axum_msg) => { let tungstenite_msg = match axum_msg { axum::extract::ws::Message::Text(text) => { tungstenite::Message::Text(text.to_string().into()) } axum::extract::ws::Message::Binary(data) => { tungstenite::Message::Binary(data.to_vec().into()) } axum::extract::ws::Message::Ping(data) => { tungstenite::Message::Ping(data.to_vec().into()) } axum::extract::ws::Message::Pong(data) => { tungstenite::Message::Pong(data.to_vec().into()) } axum::extract::ws::Message::Close(close_frame) => { let close = close_frame.map(|cf| tungstenite::protocol::CloseFrame { code: tungstenite::protocol::frame::coding::CloseCode::from( cf.code, ), reason: cf.reason.to_string().into(), }); tungstenite::Message::Close(close) } }; if dev_sender.send(tungstenite_msg).await.is_err() { break; } } Err(e) => { tracing::debug!("Client WebSocket receive error: {}", e); break; } } } let _ = dev_sender.close().await; }); let dev_to_client = tokio::spawn(async move { while let Some(msg_result) = dev_receiver.next().await { match msg_result { Ok(tungstenite_msg) => { let axum_msg = match tungstenite_msg { tungstenite::Message::Text(text) => { axum::extract::ws::Message::Text(text.to_string().into()) } tungstenite::Message::Binary(data) => { axum::extract::ws::Message::Binary(data.to_vec().into()) } tungstenite::Message::Ping(data) => { axum::extract::ws::Message::Ping(data.to_vec().into()) } tungstenite::Message::Pong(data) => { axum::extract::ws::Message::Pong(data.to_vec().into()) } tungstenite::Message::Close(close_frame) => { let close = close_frame.map(|cf| axum::extract::ws::CloseFrame { code: cf.code.into(), reason: cf.reason.to_string().into(), }); axum::extract::ws::Message::Close(close) } tungstenite::Message::Frame(_) => continue, }; if client_sender.send(axum_msg).await.is_err() { break; } } Err(e) => { tracing::debug!("Dev server WebSocket receive error: {}", e); break; } } } let _ = client_sender.close().await; }); tokio::select! { _ = client_to_dev => { tracing::debug!("Client to dev server forwarding completed"); } _ = dev_to_client => { tracing::debug!("Dev server to client forwarding completed"); } } Ok(()) } pub fn router() -> Router where S: Clone + Send + Sync + 'static, { Router::new() .fallback(subdomain_proxy) .layer(ValidateRequestHeaderLayer::custom( crate::middleware::validate_origin, )) } #[derive(Debug, Clone, PartialEq)] struct RscRedirectInfo { url: String, redirect_type: String, status_code: u16, } /// Detects Next.js RSC redirect instructions encoded in flight data response bodies. /// /// Next.js `redirect()` in Server Components serializes the redirect as an error /// with digest `NEXT_REDIRECT;{type};{url};{statusCode};` inside the flight data. /// This function scans the body for this pattern and extracts the redirect info. /// /// Returns `None` if no redirect is found, if the body is too large (>1MB), /// or if the digest format is invalid. fn detect_rsc_redirect_in_body(body: &[u8]) -> Option { // Skip bodies larger than 1MB if body.len() > 1_048_576 { return None; } let body_str = String::from_utf8_lossy(body); // Find the reliable marker: "digest":"NEXT_REDIRECT; let marker = "\"digest\":\"NEXT_REDIRECT;"; let marker_pos = body_str.find(marker)?; // Extract the full digest value starting after '"digest":"' let digest_prefix = "\"digest\":\""; let digest_start = marker_pos + digest_prefix.len(); let remaining = &body_str[digest_start..]; // Find the closing unescaped quote let digest_end = remaining.find('"')?; let digest = &remaining[..digest_end]; // Parse the digest: NEXT_REDIRECT;{type};{url};{statusCode}; let parts: Vec<&str> = digest.split(';').collect(); // Minimum: ["NEXT_REDIRECT", type, url, statusCode, ""] if parts.len() < 5 { return None; } if parts[0] != "NEXT_REDIRECT" { return None; } let redirect_type = parts[1]; if redirect_type != "push" && redirect_type != "replace" { return None; } // Last element must be empty (trailing semicolon) if !parts[parts.len() - 1].is_empty() { return None; } // Second-to-last is the status code let status_str = parts[parts.len() - 2]; let status_code: u16 = status_str.parse().ok()?; // Validate status code if !matches!(status_code, 301 | 302 | 303 | 307 | 308) { return None; } // URL is everything between type and status code (handles URLs with semicolons) let url = parts[2..parts.len() - 2].join(";"); Some(RscRedirectInfo { url, redirect_type: redirect_type.to_string(), status_code, }) } #[cfg(test)] mod tests { use axum::http::header::{ CACHE_CONTROL, CONTENT_LENGTH, CONTENT_SECURITY_POLICY, LOCATION, SET_COOKIE, }; use super::*; #[test] fn collect_response_headers_preserves_multiple_set_cookie_values() { let mut upstream_headers = HeaderMap::new(); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("first=1; Path=/")); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("second=2; Path=/")); let proxied = collect_response_headers(&upstream_headers, false); let set_cookie_values: Vec> = proxied .iter() .filter(|(name, _)| *name == SET_COOKIE) .map(|(_, value)| value.as_bytes().to_vec()) .collect(); assert_eq!( set_cookie_values, vec![b"first=1; Path=/".to_vec(), b"second=2; Path=/".to_vec()] ); } #[test] fn response_builder_preserves_multiple_set_cookie_values() { let mut upstream_headers = HeaderMap::new(); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("first=1; Path=/")); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("second=2; Path=/")); let response_headers = collect_response_headers(&upstream_headers, false); let mut builder = Response::builder().status(StatusCode::OK); for (name, value) in &response_headers { builder = builder.header(name.clone(), value.clone()); } let response = builder.body(Body::empty()).expect("response builds"); assert_eq!(response.headers().get_all(SET_COOKIE).iter().count(), 2); } #[test] fn collect_response_headers_preserves_mixed_headers_and_three_cookies() { let mut upstream_headers = HeaderMap::new(); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("first=1; Path=/")); upstream_headers.append(CACHE_CONTROL, HeaderValue::from_static("no-store")); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("second=2; Path=/")); upstream_headers.append(SET_COOKIE, HeaderValue::from_static("third=3; Path=/")); upstream_headers.insert("x-custom-header", HeaderValue::from_static("present")); let proxied = collect_response_headers(&upstream_headers, false); assert_eq!( proxied .iter() .filter(|(name, _)| *name == SET_COOKIE) .count(), 3 ); assert!( proxied.iter().any(|(name, value)| *name == CACHE_CONTROL && value == HeaderValue::from_static("no-store")) ); assert!(proxied.iter().any(|(name, value)| name == "x-custom-header" && value == HeaderValue::from_static("present"))); } #[test] fn collect_response_headers_drops_content_length_for_html_only() { let mut upstream_headers = HeaderMap::new(); upstream_headers.insert(CONTENT_LENGTH, HeaderValue::from_static("123")); let html_headers = collect_response_headers(&upstream_headers, true); assert!(html_headers.iter().all(|(name, _)| *name != CONTENT_LENGTH)); let non_html_headers = collect_response_headers(&upstream_headers, false); assert_eq!(non_html_headers.len(), 1); assert_eq!(non_html_headers[0].0, CONTENT_LENGTH); } #[test] fn collect_response_headers_strips_blocked_headers() { let mut upstream_headers = HeaderMap::new(); upstream_headers.insert( CONTENT_SECURITY_POLICY, HeaderValue::from_static("frame-ancestors 'none'"), ); let proxied = collect_response_headers(&upstream_headers, false); assert!( proxied .iter() .all(|(name, _)| *name != CONTENT_SECURITY_POLICY) ); } #[test] fn rewrite_redirect_like_header_value_rewrites_loopback_absolute_url() { let rewritten = rewrite_redirect_like_header_value( "http://localhost:4000/generate?from=auth#done", 4000, 3009, ); assert_eq!( rewritten.as_deref(), Some("http://4000.localhost:3009/generate?from=auth#done") ); } #[test] fn rewrite_redirect_like_header_value_keeps_relative_and_non_loopback_urls() { assert_eq!( rewrite_redirect_like_header_value("/generate", 4000, 3009), None ); assert_eq!( rewrite_redirect_like_header_value("?from=auth", 4000, 3009), None ); assert_eq!( rewrite_redirect_like_header_value("https://example.com/generate", 4000, 3009), None ); } #[test] fn rewrite_redirect_like_header_value_rewrites_scheme_relative_loopback_url() { let rewritten = rewrite_redirect_like_header_value("//localhost:4000/generate", 4000, 3009); assert_eq!( rewritten.as_deref(), Some("http://4000.localhost:3009/generate") ); } #[test] fn rewrite_refresh_header_value_rewrites_embedded_url() { let rewritten = rewrite_refresh_header_value( "0; URL='http://localhost:4000/generate?from=auth'", 4000, 3009, ); assert_eq!( rewritten.as_deref(), Some("0; url=http://4000.localhost:3009/generate?from=auth") ); } #[test] fn rewrite_refresh_header_value_handles_trailing_comma_in_quoted_url() { let rewritten = rewrite_refresh_header_value( "0; URL=\"http://localhost:4000/?_refresh=7\",", 4000, 3009, ); assert_eq!( rewritten.as_deref(), Some("0; url=http://4000.localhost:3009/?_refresh=7") ); } #[test] fn rewrite_redirect_like_header_value_cleans_quoted_relative_url() { let rewritten = rewrite_redirect_like_header_value("\"/generate\",", 4000, 3009); assert_eq!(rewritten.as_deref(), Some("/generate")); } #[test] fn rewrite_redirect_like_header_value_cleans_and_rewrites_quoted_absolute_url() { let rewritten = rewrite_redirect_like_header_value("\"http://localhost:4000/generate\",", 4000, 3009); assert_eq!( rewritten.as_deref(), Some("http://4000.localhost:3009/generate") ); } #[test] fn rewrite_redirect_like_header_value_skips_structured_values() { let rewritten = rewrite_redirect_like_header_value( "url=\"http://localhost:4000/generate\", mode=replace", 4000, 3009, ); assert_eq!(rewritten, None); } #[test] fn rewrite_redirect_like_headers_rewrites_generic_redirect_headers_only() { let mut headers = vec![ ( LOCATION, HeaderValue::from_static("http://localhost:4000/generate"), ), ( HeaderName::from_static("x-auth-redirect-url"), HeaderValue::from_static("http://localhost:4000/generate"), ), ( HeaderName::from_static("refresh"), HeaderValue::from_static("0; url=http://localhost:4000/generate"), ), ( HeaderName::from_static("x-custom-header"), HeaderValue::from_static("http://localhost:4000/keep"), ), ]; rewrite_redirect_like_headers(&mut headers, 4000, Some(3009)); assert_eq!( headers[0].1, HeaderValue::from_static("http://4000.localhost:3009/generate") ); assert_eq!( headers[1].1, HeaderValue::from_static("http://4000.localhost:3009/generate") ); assert_eq!( headers[2].1, HeaderValue::from_static("0; url=http://4000.localhost:3009/generate") ); assert_eq!( headers[3].1, HeaderValue::from_static("http://localhost:4000/keep") ); } #[test] fn rewrite_redirect_like_headers_rewrites_rewrite_headers_and_keeps_plain_url_headers() { let mut headers = vec![ ( HeaderName::from_static("x-router-rewrite"), HeaderValue::from_static("http://localhost:4000/generate"), ), ( HeaderName::from_static("x-target-url"), HeaderValue::from_static("http://localhost:4000/generate"), ), ]; rewrite_redirect_like_headers(&mut headers, 4000, Some(3009)); assert_eq!( headers[0].1, HeaderValue::from_static("http://4000.localhost:3009/generate") ); assert_eq!( headers[1].1, HeaderValue::from_static("http://localhost:4000/generate") ); } #[test] fn is_redirect_like_header_name_matches_nextjs_redirect() { assert!(is_redirect_like_header_name("x-nextjs-redirect")); assert!(!is_redirect_like_header_name("x-nextjs-data")); assert!(!is_redirect_like_header_name("rsc")); } #[test] fn is_redirect_like_header_name_matches_action_redirect() { // x-action-redirect contains "redirect" so it matches, // but our interception logic specifically looks for x-nextjs-redirect assert!(is_redirect_like_header_name("x-action-redirect")); } #[test] fn rewrite_redirect_like_headers_rewrites_nextjs_redirect() { let mut headers = vec![( HeaderName::from_static("x-nextjs-redirect"), HeaderValue::from_static("http://localhost:4000/generate"), )]; rewrite_redirect_like_headers(&mut headers, 4000, Some(3009)); assert_eq!( headers[0].1, HeaderValue::from_static("http://4000.localhost:3009/generate") ); } #[test] fn collect_response_headers_preserves_nextjs_redirect() { let mut upstream_headers = HeaderMap::new(); upstream_headers.insert( HeaderName::from_static("x-nextjs-redirect"), HeaderValue::from_static("/generate"), ); let proxied = collect_response_headers(&upstream_headers, false); assert_eq!(proxied.len(), 1); assert_eq!(proxied[0].0, "x-nextjs-redirect"); assert_eq!(proxied[0].1, "/generate"); } #[test] fn rewrite_redirect_like_headers_preserves_relative_nextjs_redirect() { let mut headers = vec![( HeaderName::from_static("x-nextjs-redirect"), HeaderValue::from_static("/generate"), )]; rewrite_redirect_like_headers(&mut headers, 4000, Some(3009)); // Relative URLs are NOT rewritten — only absolute loopback URLs are assert_eq!(headers[0].1, HeaderValue::from_static("/generate")); } #[test] fn test_detect_rsc_redirect_basic() { let body = b"0:\"$Sreact.suspense\"\n1:I[\"123\",[]]\"]\n3:E{\"digest\":\"NEXT_REDIRECT;replace;/generate;307;\",\"message\":\"NEXT_REDIRECT\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!( result, Some(RscRedirectInfo { url: "/generate".to_string(), redirect_type: "replace".to_string(), status_code: 307, }) ); } #[test] fn test_detect_rsc_redirect_url_with_semicolons() { let body = b"{\"digest\":\"NEXT_REDIRECT;push;/path;with;semicolons;308;\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!( result, Some(RscRedirectInfo { url: "/path;with;semicolons".to_string(), redirect_type: "push".to_string(), status_code: 308, }) ); } #[test] fn test_detect_rsc_redirect_false_positive_no_json_prefix() { let body = b"The error NEXT_REDIRECT; was logged"; let result = detect_rsc_redirect_in_body(body); assert_eq!(result, None); } #[test] fn test_detect_rsc_redirect_body_size_cap() { let mut body = vec![0u8; 1_048_577]; let payload = b"{\"digest\":\"NEXT_REDIRECT;replace;/generate;307;\"}"; body[..payload.len()].copy_from_slice(payload); let result = detect_rsc_redirect_in_body(&body); assert_eq!(result, None); } #[test] fn test_detect_rsc_redirect_invalid_type() { let body = b"{\"digest\":\"NEXT_REDIRECT;invalid;/url;307;\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!(result, None); } #[test] fn test_detect_rsc_redirect_invalid_status_code() { let body = b"{\"digest\":\"NEXT_REDIRECT;replace;/url;999;\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!(result, None); } #[test] fn test_detect_rsc_redirect_permanent_redirect() { let body = b"{\"digest\":\"NEXT_REDIRECT;replace;/permanent;301;\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!( result, Some(RscRedirectInfo { url: "/permanent".to_string(), redirect_type: "replace".to_string(), status_code: 301, }) ); } #[test] fn test_detect_rsc_redirect_absolute_url() { let body = b"{\"digest\":\"NEXT_REDIRECT;push;https://example.com/callback;307;\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!( result, Some(RscRedirectInfo { url: "https://example.com/callback".to_string(), redirect_type: "push".to_string(), status_code: 307, }) ); } #[test] fn test_detect_rsc_redirect_empty_body() { let result = detect_rsc_redirect_in_body(b""); assert_eq!(result, None); } #[test] fn test_detect_rsc_redirect_no_redirect_in_body() { let body = b"0:\"$Sreact.suspense\"\n1:I[\"456\",[]]\"]\n2:{\"name\":\"MyComponent\"}"; let result = detect_rsc_redirect_in_body(body); assert_eq!(result, None); } } ================================================ FILE: crates/server/src/routes/approvals.rs ================================================ use axum::{ Router, extract::{State, ws::Message}, http::StatusCode, response::{IntoResponse, Json as ResponseJson}, routing::{get, post}, }; use deployment::Deployment; use futures_util::StreamExt; use utils::{ approvals::{ApprovalOutcome, ApprovalResponse}, log_msg::LogMsg, response::ApiResponse, }; use crate::{ DeploymentImpl, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, }; pub async fn respond_to_approval( State(deployment): State, axum::extract::Path(id): axum::extract::Path, ResponseJson(request): ResponseJson, ) -> Result>, StatusCode> { let service = deployment.approvals(); match service.respond(&id, request).await { Ok((outcome, context)) => { deployment .track_if_analytics_allowed( "approval_responded", serde_json::json!({ "approval_id": &id, "status": format!("{:?}", outcome), "tool_name": context.tool_name, "execution_process_id": context.execution_process_id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(outcome))) } Err(e) => { tracing::error!("Failed to respond to approval: {:?}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } pub async fn stream_approvals_ws( ws: SignedWsUpgrade, State(deployment): State, ) -> impl IntoResponse { ws.on_upgrade(move |socket| async move { if let Err(e) = handle_approvals_ws(socket, deployment).await { tracing::warn!("approvals WS closed: {}", e); } }) } async fn handle_approvals_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, ) -> anyhow::Result<()> { let mut stream = deployment.approvals().patch_stream(); if let Some(snapshot_patch) = stream.next().await { socket .send(LogMsg::JsonPatch(snapshot_patch).to_ws_message_unchecked()) .await?; } else { return Ok(()); } socket.send(LogMsg::Ready.to_ws_message_unchecked()).await?; loop { tokio::select! { patch = stream.next() => { let Some(patch) = patch else { break; }; if socket .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked()) .await .is_err() { break; } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(error) => { tracing::warn!("approvals WS receive error: {}", error); break; } } } } } Ok(()) } pub fn router() -> Router { Router::new() .route("/approvals/{id}/respond", post(respond_to_approval)) .route("/approvals/stream/ws", get(stream_approvals_ws)) } ================================================ FILE: crates/server/src/routes/attachments.rs ================================================ use axum::{ Router, body::Body, extract::{DefaultBodyLimit, Multipart, Path, State}, http::{StatusCode, header}, response::{Json as ResponseJson, Response}, routing::{delete, get, post}, }; use chrono::{DateTime, Utc}; use db::models::file::{File, WorkspaceAttachment}; use deployment::Deployment; use serde::{Deserialize, Serialize}; use services::services::file::FileError; use tokio::fs::File as TokioFile; use tokio_util::io::ReaderStream; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; pub(crate) fn content_type_and_disposition_for_attachment( mime_type: &str, ) -> (&str, Option<&'static str>) { if is_safe_inline_attachment_mime_type(mime_type) { (mime_type, None) } else { ("application/octet-stream", Some("attachment")) } } fn is_safe_inline_attachment_mime_type(mime_type: &str) -> bool { matches!( mime_type, "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/bmp" | "image/x-icon" | "image/tiff" ) } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct AttachmentResponse { pub id: Uuid, pub file_path: String, // relative path to display in markdown pub original_name: String, pub mime_type: Option, pub size_bytes: i64, pub hash: String, pub created_at: DateTime, pub updated_at: DateTime, } impl AttachmentResponse { pub fn from_file(file: File) -> Self { let markdown_path = format!("{}/{}", utils::path::VIBE_ATTACHMENTS_DIR, file.file_path); Self { id: file.id, file_path: markdown_path, original_name: file.original_name, mime_type: file.mime_type, size_bytes: file.size_bytes, hash: file.hash, created_at: file.created_at, updated_at: file.updated_at, } } } #[derive(Debug, Serialize, Deserialize, TS)] pub struct AttachmentMetadata { pub exists: bool, pub file_name: Option, pub path: Option, pub size_bytes: Option, pub format: Option, pub proxy_url: Option, } pub async fn upload_file( State(deployment): State, multipart: Multipart, ) -> Result>, ApiError> { let file_response = process_file_upload(&deployment, multipart, None).await?; Ok(ResponseJson(ApiResponse::success(file_response))) } pub(crate) async fn process_file_upload( deployment: &DeploymentImpl, mut multipart: Multipart, link_workspace_id: Option, ) -> Result { let file_service = deployment.file(); while let Some(field) = multipart.next_field().await? { if field.name() == Some("image") { let filename = field .file_name() .map(|s| s.to_string()) .unwrap_or_else(|| "file.bin".to_string()); let data = field.bytes().await?; let file = file_service.store_file(&data, &filename).await?; if let Some(workspace_id) = link_workspace_id { WorkspaceAttachment::associate_many_dedup( &deployment.db().pool, workspace_id, std::slice::from_ref(&file.id), ) .await?; } deployment .track_if_analytics_allowed( "file_uploaded", serde_json::json!({ "file_id": file.id.to_string(), "size_bytes": file.size_bytes, "mime_type": file.mime_type, "workspace_id": link_workspace_id.map(|id| id.to_string()), }), ) .await; return Ok(AttachmentResponse::from_file(file)); } } Err(ApiError::File(FileError::NotFound)) } pub async fn serve_file( Path(file_id): Path, State(deployment): State, ) -> Result { let file_service = deployment.file(); let file_record = file_service .get_file(file_id) .await? .ok_or_else(|| ApiError::File(FileError::NotFound))?; let file_path = file_service.get_absolute_path(&file_record); let file = TokioFile::open(&file_path).await?; let metadata = file.metadata().await?; let stream = ReaderStream::new(file); let body = Body::from_stream(stream); let content_type = file_record .mime_type .as_deref() .unwrap_or("application/octet-stream"); let (content_type, content_disposition) = content_type_and_disposition_for_attachment(content_type); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_LENGTH, metadata.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000") .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff"); if let Some(content_disposition) = content_disposition { response = response.header(header::CONTENT_DISPOSITION, content_disposition); } let response = response .body(body) .map_err(|e| ApiError::File(FileError::ResponseBuildError(e.to_string())))?; Ok(response) } pub async fn delete_file( Path(file_id): Path, State(deployment): State, ) -> Result>, ApiError> { let file_service = deployment.file(); file_service.delete_file(file_id).await?; Ok(ResponseJson(ApiResponse::success(()))) } pub fn routes() -> Router { Router::new() .route( "/upload", post(upload_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), ) .route("/{id}/file", get(serve_file)) .route("/{id}", delete(delete_file)) } #[cfg(test)] mod tests { use axum::http::header; use super::content_type_and_disposition_for_attachment; #[test] fn allows_safe_images_inline() { let (content_type, disposition) = content_type_and_disposition_for_attachment("image/png"); assert_eq!(content_type, "image/png"); assert_eq!(disposition, None); } #[test] fn forces_html_to_download() { let (content_type, disposition) = content_type_and_disposition_for_attachment("text/html"); assert_eq!(content_type, "application/octet-stream"); assert_eq!(disposition, Some("attachment")); } #[test] fn forces_svg_to_download() { let (content_type, disposition) = content_type_and_disposition_for_attachment("image/svg+xml"); assert_eq!(content_type, "application/octet-stream"); assert_eq!(disposition, Some("attachment")); } #[test] fn forces_pdf_to_download() { let (content_type, disposition) = content_type_and_disposition_for_attachment("application/pdf"); assert_eq!(content_type, "application/octet-stream"); assert_eq!(disposition, Some("attachment")); } #[test] fn forces_unknown_types_to_download() { let (content_type, disposition) = content_type_and_disposition_for_attachment("application/octet-stream"); assert_eq!(content_type, "application/octet-stream"); assert_eq!(disposition, Some("attachment")); } #[test] fn nosniff_header_name_matches_expected() { assert_eq!( header::X_CONTENT_TYPE_OPTIONS.as_str(), "x-content-type-options" ); } } ================================================ FILE: crates/server/src/routes/config.rs ================================================ use std::collections::HashMap; use api_types::LoginStatus; use axum::{ Json, Router, body::Body, extract::{Path, Query, State, ws::Message}, http, response::{IntoResponse, Json as ResponseJson, Response}, routing::{get, put}, }; use deployment::{Deployment, DeploymentError}; use executors::{ executors::{ AvailabilityInfo, BaseAgentCapability, BaseCodingAgent, StandardCodingAgentExecutor, }, mcp_config::{McpConfig, read_agent_config, write_agent_config}, profile::{ExecutorConfigs, ExecutorProfileId}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use services::services::{ config::{ Config, ConfigError, SoundFile, editor::{EditorConfig, EditorType}, save_config_to_file, }, container::ContainerService, }; use tokio::fs; use ts_rs::TS; use utils::{assets::config_path, log_msg::LogMsg, response::ApiResponse}; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, tunnel, }; pub fn router() -> Router { Router::new() .route("/info", get(get_user_system_info)) .route("/config", put(update_config)) .route("/sounds/{sound}", get(get_sound)) .route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers)) .route("/profiles", get(get_profiles).put(update_profiles)) .route( "/editors/check-availability", get(check_editor_availability), ) .route("/agents/check-availability", get(check_agent_availability)) .route("/agents/preset-options", get(get_agent_preset_options)) .route( "/agents/discovered-options/ws", get(stream_executor_discovered_options_ws), ) } #[derive(Debug, Serialize, Deserialize, TS)] pub struct Environment { pub os_type: String, pub os_version: String, pub os_architecture: String, pub bitness: String, } impl Default for Environment { fn default() -> Self { Self::new() } } impl Environment { pub fn new() -> Self { let info = os_info::get(); Environment { os_type: info.os_type().to_string(), os_version: info.version().to_string(), os_architecture: info.architecture().unwrap_or("unknown").to_string(), bitness: info.bitness().to_string(), } } } #[derive(Debug, Serialize, Deserialize, TS)] pub struct UserSystemInfo { pub version: String, pub config: Config, pub analytics_user_id: String, pub login_status: LoginStatus, #[serde(flatten)] pub profiles: ExecutorConfigs, pub environment: Environment, /// Capabilities supported per executor (e.g., { "CLAUDE_CODE": ["SESSION_FORK"] }) pub capabilities: HashMap>, pub shared_api_base: Option, pub preview_proxy_port: Option, } // TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants #[axum::debug_handler] async fn get_user_system_info( State(deployment): State, ) -> ResponseJson> { let config = deployment.config().read().await; let login_status = tokio::time::timeout( std::time::Duration::from_secs(2), deployment.get_login_status(), ) .await .unwrap_or(LoginStatus::LoggedOut); let user_system_info = UserSystemInfo { version: env!("CARGO_PKG_VERSION").to_string(), config: config.clone(), analytics_user_id: deployment.user_id().to_string(), login_status, profiles: ExecutorConfigs::get_cached(), environment: Environment::new(), capabilities: { let mut caps: HashMap> = HashMap::new(); let profs = ExecutorConfigs::get_cached(); for key in profs.executors.keys() { if let Some(agent) = profs.get_coding_agent(&ExecutorProfileId::new(*key)) { caps.insert(key.to_string(), agent.capabilities()); } } caps }, shared_api_base: deployment.shared_api_base(), preview_proxy_port: crate::preview_proxy::get_proxy_port(), }; ResponseJson(ApiResponse::success(user_system_info)) } async fn update_config( State(deployment): State, Json(new_config): Json, ) -> ResponseJson> { let config_path = config_path(); // Validate git branch prefix if !git::is_valid_branch_prefix(&new_config.git_branch_prefix) { return ResponseJson(ApiResponse::error( "Invalid git branch prefix. Must be a valid git branch name component without slashes.", )); } // Get old config state before updating let old_config = deployment.config().read().await.clone(); match save_config_to_file(&new_config, &config_path).await { Ok(_) => { let mut config = deployment.config().write().await; *config = new_config.clone(); drop(config); // Track config events when fields transition from false → true and run side effects handle_config_events(&deployment, &old_config, &new_config).await; ResponseJson(ApiResponse::success(new_config)) } Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))), } } /// Track config events when fields transition from false → true async fn track_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) { let events = [ ( !old.disclaimer_acknowledged && new.disclaimer_acknowledged, "onboarding_disclaimer_accepted", serde_json::json!({}), ), ( !old.onboarding_acknowledged && new.onboarding_acknowledged, "onboarding_completed", serde_json::json!({ "profile": new.executor_profile, "editor": new.editor }), ), ( !old.analytics_enabled && new.analytics_enabled, "analytics_session_start", serde_json::json!({}), ), ]; for (should_track, event_name, properties) in events { if should_track { deployment .track_if_analytics_allowed(event_name, properties) .await; } } } async fn handle_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) { track_config_events(deployment, old, new).await; let old_relay_host_name = tunnel::effective_relay_host_name(old, deployment.user_id()); let new_relay_host_name = tunnel::effective_relay_host_name(new, deployment.user_id()); deployment .server_info() .set_hostname(new_relay_host_name.clone()) .await; match (old.relay_enabled, new.relay_enabled) { (false, true) => tunnel::spawn_relay(deployment).await, (true, false) => tunnel::stop_relay(deployment).await, (true, true) => { if old_relay_host_name != new_relay_host_name { tunnel::spawn_relay(deployment).await; } } (false, false) => (), } } async fn get_sound(Path(sound): Path) -> Result { let sound = sound.serve().await.map_err(DeploymentError::Other)?; let response = Response::builder() .status(http::StatusCode::OK) .header( http::header::CONTENT_TYPE, http::HeaderValue::from_static("audio/wav"), ) .body(Body::from(sound.data.into_owned())) .unwrap(); Ok(response) } #[derive(TS, Debug, Deserialize)] pub struct McpServerQuery { executor: BaseCodingAgent, } #[derive(TS, Debug, Serialize, Deserialize)] pub struct GetMcpServerResponse { // servers: HashMap, mcp_config: McpConfig, config_path: String, } #[derive(TS, Debug, Serialize, Deserialize)] pub struct UpdateMcpServersBody { servers: HashMap, } async fn get_mcp_servers( State(_deployment): State, Query(query): Query, ) -> Result>, ApiError> { let coding_agent = ExecutorConfigs::get_cached() .get_coding_agent(&ExecutorProfileId::new(query.executor)) .ok_or(ConfigError::ValidationError( "Executor not found".to_string(), ))?; if !coding_agent.supports_mcp() { return Ok(ResponseJson(ApiResponse::error( "MCP not supported by this executor", ))); } // Resolve supplied config path or agent default let config_path = match coding_agent.default_mcp_config_path() { Some(path) => path, None => { return Ok(ResponseJson(ApiResponse::error( "Could not determine config file path", ))); } }; let mut mcpc = coding_agent.get_mcp_config(); let raw_config = read_agent_config(&config_path, &mcpc).await?; let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path); mcpc.set_servers(servers); Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse { mcp_config: mcpc, config_path: config_path.to_string_lossy().to_string(), }))) } async fn update_mcp_servers( State(_deployment): State, Query(query): Query, Json(payload): Json, ) -> Result>, ApiError> { let profiles = ExecutorConfigs::get_cached(); let agent = profiles .get_coding_agent(&ExecutorProfileId::new(query.executor)) .ok_or(ConfigError::ValidationError( "Executor not found".to_string(), ))?; if !agent.supports_mcp() { return Ok(ResponseJson(ApiResponse::error( "This executor does not support MCP servers", ))); } // Resolve supplied config path or agent default let config_path = match agent.default_mcp_config_path() { Some(path) => path.to_path_buf(), None => { return Ok(ResponseJson(ApiResponse::error( "Could not determine config file path", ))); } }; let mcpc = agent.get_mcp_config(); match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await { Ok(message) => Ok(ResponseJson(ApiResponse::success(message))), Err(e) => Ok(ResponseJson(ApiResponse::error(&format!( "Failed to update MCP servers: {}", e )))), } } async fn update_mcp_servers_in_config( config_path: &std::path::Path, mcpc: &McpConfig, new_servers: HashMap, ) -> Result> { // Ensure parent directory exists if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).await?; } // Read existing config (JSON or TOML depending on agent) let mut config = read_agent_config(config_path, mcpc).await?; // Get the current server count for comparison let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len(); // Set the MCP servers using the correct attribute path set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?; // Write the updated config back to file (JSON or TOML depending on agent) write_agent_config(config_path, mcpc, &config).await?; let new_count = new_servers.len(); let message = match (old_servers, new_count) { (0, 0) => "No MCP servers configured".to_string(), (0, n) => format!("Added {} MCP server(s)", n), (old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new), (old, new) => format!( "Updated MCP server configuration (was {}, now {})", old, new ), }; Ok(message) } /// Helper function to get MCP servers from config using a path fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap { let mut current = raw_config; for part in path { current = match current.get(part) { Some(val) => val, None => return HashMap::new(), }; } // Extract the servers object match current.as_object() { Some(servers) => servers .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), None => HashMap::new(), } } /// Helper function to set MCP servers in config using a path fn set_mcp_servers_in_config_path( raw_config: &mut Value, path: &[String], servers: &HashMap, ) -> Result<(), Box> { // Ensure config is an object if !raw_config.is_object() { *raw_config = serde_json::json!({}); } let mut current = raw_config; // Navigate/create the nested structure (all parts except the last) for part in &path[..path.len() - 1] { if current.get(part).is_none() { current .as_object_mut() .unwrap() .insert(part.to_string(), serde_json::json!({})); } current = current.get_mut(part).unwrap(); if !current.is_object() { *current = serde_json::json!({}); } } // Set the final attribute let final_attr = path.last().unwrap(); current .as_object_mut() .unwrap() .insert(final_attr.to_string(), serde_json::to_value(servers)?); Ok(()) } #[derive(Debug, Serialize, Deserialize)] pub struct ProfilesContent { pub content: String, pub path: String, } async fn get_profiles( State(_deployment): State, ) -> ResponseJson> { let profiles_path = utils::assets::profiles_path(); // Use cached data to ensure consistency with runtime and PUT updates let profiles = ExecutorConfigs::get_cached(); let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| { tracing::error!("Failed to serialize profiles to JSON: {}", e); serde_json::to_string_pretty(&ExecutorConfigs::from_defaults()) .unwrap_or_else(|_| "{}".to_string()) }); ResponseJson(ApiResponse::success(ProfilesContent { content, path: profiles_path.display().to_string(), })) } async fn update_profiles( State(_deployment): State, body: String, ) -> ResponseJson> { // Try to parse as ExecutorProfileConfigs format match serde_json::from_str::(&body) { Ok(executor_profiles) => { // Save the profiles to file match executor_profiles.save_overrides() { Ok(_) => { tracing::info!("Executor profiles saved successfully"); // Reload the cached profiles ExecutorConfigs::reload(); ResponseJson(ApiResponse::success( "Executor profiles updated successfully".to_string(), )) } Err(e) => { tracing::error!("Failed to save executor profiles: {}", e); ResponseJson(ApiResponse::error(&format!( "Failed to save executor profiles: {}", e ))) } } } Err(e) => ResponseJson(ApiResponse::error(&format!( "Invalid executor profiles format: {}", e ))), } } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CheckEditorAvailabilityQuery { editor_type: EditorType, } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CheckEditorAvailabilityResponse { available: bool, } async fn check_editor_availability( State(_deployment): State, Query(query): Query, ) -> ResponseJson> { // Construct a minimal EditorConfig for checking let editor_config = EditorConfig::new( query.editor_type, None, // custom_command None, // remote_ssh_host None, // remote_ssh_user false, // auto_install_extension ); let available = editor_config.check_availability().await; ResponseJson(ApiResponse::success(CheckEditorAvailabilityResponse { available, })) } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CheckAgentAvailabilityQuery { executor: BaseCodingAgent, } async fn check_agent_availability( State(_deployment): State, Query(query): Query, ) -> ResponseJson> { let profiles = ExecutorConfigs::get_cached(); let profile_id = ExecutorProfileId::new(query.executor); let info = match profiles.get_coding_agent(&profile_id) { Some(agent) => agent.get_availability_info(), None => AvailabilityInfo::NotFound, }; ResponseJson(ApiResponse::success(info)) } #[derive(Debug, Deserialize, TS)] pub struct AgentPresetOptionsQuery { pub executor: BaseCodingAgent, pub variant: Option, } async fn get_agent_preset_options( Query(query): Query, ) -> ResponseJson> { let profiles = ExecutorConfigs::get_cached(); let profile_id = if let Some(variant) = query.variant { ExecutorProfileId::with_variant(query.executor, variant) } else { ExecutorProfileId::new(query.executor) }; let options = match profiles.get_coding_agent(&profile_id) { Some(agent) => agent.get_preset_options(), None => { // Return a default config if not found executors::profile::ExecutorConfig::new(query.executor) } }; ResponseJson(ApiResponse::success(options)) } #[derive(Debug, Deserialize)] pub struct ExecutorDiscoveredOptionsStreamQuery { executor: BaseCodingAgent, #[serde(default)] session_id: Option, #[serde(default)] workspace_id: Option, #[serde(default)] repo_id: Option, } pub async fn stream_executor_discovered_options_ws( ws: SignedWsUpgrade, State(deployment): State, Query(query): Query, ) -> impl IntoResponse { ws.on_upgrade(move |socket| async move { if let Err(e) = handle_executor_discovered_options_ws(socket, deployment, query).await { tracing::warn!("discovered options WS closed: {}", e); } }) } async fn handle_executor_discovered_options_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, query: ExecutorDiscoveredOptionsStreamQuery, ) -> anyhow::Result<()> { use futures_util::StreamExt; match deployment .container() .discover_executor_options( ExecutorProfileId::new(query.executor), query.session_id, query.workspace_id, query.repo_id, ) .await { Ok(Some(mut stream)) => { if let Some(patch) = stream.next().await { let _ = socket .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked()) .await; } let _ = socket.send(LogMsg::Ready.to_ws_message_unchecked()).await; loop { tokio::select! { patch = stream.next() => { let Some(patch) = patch else { break; }; if socket .send(LogMsg::JsonPatch(patch).to_ws_message_unchecked()) .await .is_err() { break; } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } } Ok(None) => { let _ = socket.send(LogMsg::Ready.to_ws_message_unchecked()).await; } Err(e) => { tracing::warn!("Failed to start discovered options stream: {}", e); } } let _ = socket .send(LogMsg::Finished.to_ws_message_unchecked()) .await; Ok(()) } ================================================ FILE: crates/server/src/routes/containers.rs ================================================ use axum::{ Router, extract::{Query, State}, response::Json as ResponseJson, routing::get, }; use db::models::{ requests::ContainerQuery, workspace::{Workspace, WorkspaceContext}, }; use deployment::Deployment; use serde::Serialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Serialize)] pub struct ContainerInfo { pub attempt_id: Uuid, } pub async fn get_container_info( Query(query): Query, State(deployment): State, ) -> Result>, ApiError> { let info = Workspace::resolve_container_ref_by_prefix(&deployment.db().pool, &query.container_ref) .await .map_err(ApiError::Database)?; Ok(ResponseJson(ApiResponse::success(ContainerInfo { attempt_id: info.workspace_id, }))) } pub async fn get_context( State(deployment): State, Query(payload): Query, ) -> Result>, ApiError> { let info = Workspace::resolve_container_ref_by_prefix(&deployment.db().pool, &payload.container_ref) .await .map_err(ApiError::Database)?; let ctx = Workspace::load_context(&deployment.db().pool, info.workspace_id).await?; Ok(ResponseJson(ApiResponse::success(ctx))) } pub fn router(_deployment: &DeploymentImpl) -> Router { Router::new() // NOTE: /containers/info is required by the VSCode extension (vibe-kanban-vscode) // to auto-detect workspaces. It maps workspace_id to attempt_id for compatibility. // Do not remove this endpoint without updating the extension. .route("/containers/info", get(get_container_info)) .route("/containers/attempt-context", get(get_context)) } ================================================ FILE: crates/server/src/routes/events.rs ================================================ use axum::{ BoxError, Router, extract::State, response::{ Sse, sse::{Event, KeepAlive}, }, routing::get, }; use deployment::Deployment; use futures_util::TryStreamExt; use crate::DeploymentImpl; pub async fn events( State(deployment): State, ) -> Result>>, axum::http::StatusCode> { // Ask the container service for a combined "history + live" stream let stream = deployment.stream_events().await; Ok(Sse::new(stream.map_err(|e| -> BoxError { e.into() })).keep_alive(KeepAlive::default())) } pub fn router(_: &DeploymentImpl) -> Router { let events_router = Router::new().route("/", get(events)); Router::new().nest("/events", events_router) } ================================================ FILE: crates/server/src/routes/execution_processes.rs ================================================ use anyhow; use axum::{ Extension, Router, extract::{Path, Query, State, ws::Message}, middleware::from_fn_with_state, response::{IntoResponse, Json as ResponseJson}, routing::{get, post}, }; use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessError, ExecutionProcessStatus}, execution_process_repo_state::ExecutionProcessRepoState, }; use deployment::Deployment; use futures_util::{StreamExt, TryStreamExt}; use serde::Deserialize; use services::services::container::ContainerService; use utils::{log_msg::LogMsg, response::ApiResponse}; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, middleware::load_execution_process_middleware, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, }; #[derive(Debug, Deserialize)] pub struct SessionExecutionProcessQuery { pub session_id: Uuid, /// If true, include soft-deleted (dropped) processes in results/stream #[serde(default)] pub show_soft_deleted: Option, } pub async fn get_execution_process_by_id( Extension(execution_process): Extension, State(_deployment): State, ) -> Result>, ApiError> { Ok(ResponseJson(ApiResponse::success(execution_process))) } pub async fn stream_raw_logs_ws( ws: SignedWsUpgrade, State(deployment): State, Path(exec_id): Path, ) -> Result { // Check if the stream exists before upgrading the WebSocket let _stream = deployment .container() .stream_raw_logs(&exec_id) .await .ok_or_else(|| { ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound) })?; Ok(ws.on_upgrade(move |socket| async move { if let Err(e) = handle_raw_logs_ws(socket, deployment, exec_id).await { tracing::warn!("raw logs WS closed: {}", e); } })) } async fn handle_raw_logs_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, exec_id: Uuid, ) -> anyhow::Result<()> { use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; use executors::logs::utils::patch::ConversationPatch; use utils::log_msg::LogMsg; // Get the raw stream and convert to JSON patches on-the-fly let raw_stream = deployment .container() .stream_raw_logs(&exec_id) .await .ok_or_else(|| anyhow::anyhow!("Execution process not found"))?; let counter = Arc::new(AtomicUsize::new(0)); let mut stream = raw_stream.map_ok({ let counter = counter.clone(); move |m| match m { LogMsg::Stdout(content) => { let index = counter.fetch_add(1, Ordering::SeqCst); let patch = ConversationPatch::add_stdout(index, content); LogMsg::JsonPatch(patch).to_ws_message_unchecked() } LogMsg::Stderr(content) => { let index = counter.fetch_add(1, Ordering::SeqCst); let patch = ConversationPatch::add_stderr(index, content); LogMsg::JsonPatch(patch).to_ws_message_unchecked() } LogMsg::Finished => LogMsg::Finished.to_ws_message_unchecked(), _ => unreachable!("Raw stream should only have Stdout/Stderr/Finished"), } }); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("stream error: {}", e); break; } None => break, } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } pub async fn stream_normalized_logs_ws( ws: SignedWsUpgrade, State(deployment): State, Path(exec_id): Path, ) -> Result { let stream = deployment .container() .stream_normalized_logs(&exec_id) .await .ok_or_else(|| { ApiError::ExecutionProcess(ExecutionProcessError::ExecutionProcessNotFound) })?; // Convert the error type to anyhow::Error and turn TryStream -> Stream> let stream = stream.err_into::().into_stream(); Ok(ws.on_upgrade(move |socket| async move { if let Err(e) = handle_normalized_logs_ws(socket, stream).await { tracing::warn!("normalized logs WS closed: {}", e); } })) } async fn handle_normalized_logs_ws( mut socket: SignedWebSocket, stream: impl futures_util::Stream> + Unpin + Send + 'static, ) -> anyhow::Result<()> { let mut stream = stream.map_ok(|msg| msg.to_ws_message_unchecked()); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("stream error: {}", e); break; } None => break, } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } pub async fn stop_execution_process( Extension(execution_process): Extension, State(deployment): State, ) -> Result>, ApiError> { deployment .container() .stop_execution(&execution_process, ExecutionProcessStatus::Killed) .await?; Ok(ResponseJson(ApiResponse::success(()))) } pub async fn stream_execution_processes_by_session_ws( ws: SignedWsUpgrade, State(deployment): State, Query(query): Query, ) -> impl IntoResponse { ws.on_upgrade(move |socket| async move { if let Err(e) = handle_execution_processes_by_session_ws( socket, deployment, query.session_id, query.show_soft_deleted.unwrap_or(false), ) .await { tracing::warn!("execution processes by session WS closed: {}", e); } }) } async fn handle_execution_processes_by_session_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, session_id: uuid::Uuid, show_soft_deleted: bool, ) -> anyhow::Result<()> { // Get the raw stream and convert LogMsg to WebSocket messages let mut stream = deployment .events() .stream_execution_processes_for_session_raw(session_id, show_soft_deleted) .await? .map_ok(|msg| msg.to_ws_message_unchecked()); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("stream error: {}", e); break; } None => break, } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } pub async fn get_execution_process_repo_states( Extension(execution_process): Extension, State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let repo_states = ExecutionProcessRepoState::find_by_execution_process_id(pool, execution_process.id).await?; Ok(ResponseJson(ApiResponse::success(repo_states))) } pub fn router(deployment: &DeploymentImpl) -> Router { let workspace_id_router = Router::new() .route("/", get(get_execution_process_by_id)) .route("/stop", post(stop_execution_process)) .route("/repo-states", get(get_execution_process_repo_states)) .route("/raw-logs/ws", get(stream_raw_logs_ws)) .route("/normalized-logs/ws", get(stream_normalized_logs_ws)) .layer(from_fn_with_state( deployment.clone(), load_execution_process_middleware, )); let workspaces_router = Router::new() .route( "/stream/session/ws", get(stream_execution_processes_by_session_ws), ) .nest("/{id}", workspace_id_router); Router::new().nest("/execution-processes", workspaces_router) } ================================================ FILE: crates/server/src/routes/filesystem.rs ================================================ use axum::{ Router, extract::{Query, State}, response::Json as ResponseJson, routing::get, }; use deployment::Deployment; use serde::Deserialize; use services::services::filesystem::{DirectoryEntry, DirectoryListResponse, FilesystemError}; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListDirectoryQuery { path: Option, } pub async fn list_directory( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { match deployment.filesystem().list_directory(query.path).await { Ok(response) => Ok(ResponseJson(ApiResponse::success(response))), Err(FilesystemError::DirectoryDoesNotExist) => { Ok(ResponseJson(ApiResponse::error("Directory does not exist"))) } Err(FilesystemError::PathIsNotDirectory) => { Ok(ResponseJson(ApiResponse::error("Path is not a directory"))) } Err(FilesystemError::Io(e)) => { tracing::error!("Failed to read directory: {}", e); Ok(ResponseJson(ApiResponse::error(&format!( "Failed to read directory: {}", e )))) } } } pub async fn list_git_repos( State(deployment): State, Query(query): Query, ) -> Result>>, ApiError> { let res = if let Some(ref path) = query.path { deployment .filesystem() .list_git_repos(Some(path.clone()), 800, 1200, Some(3)) .await } else { deployment .filesystem() .list_common_git_repos(800, 1200, Some(4)) .await }; match res { Ok(response) => Ok(ResponseJson(ApiResponse::success(response))), Err(FilesystemError::DirectoryDoesNotExist) => { Ok(ResponseJson(ApiResponse::error("Directory does not exist"))) } Err(FilesystemError::PathIsNotDirectory) => { Ok(ResponseJson(ApiResponse::error("Path is not a directory"))) } Err(FilesystemError::Io(e)) => { tracing::error!("Failed to read directory: {}", e); Ok(ResponseJson(ApiResponse::error(&format!( "Failed to read directory: {}", e )))) } } } pub fn router() -> Router { Router::new() .route("/filesystem/directory", get(list_directory)) .route("/filesystem/git-repos", get(list_git_repos)) } ================================================ FILE: crates/server/src/routes/frontend.rs ================================================ use axum::{ body::Body, http::HeaderValue, response::{IntoResponse, Response}, }; use reqwest::{StatusCode, header}; use rust_embed::RustEmbed; #[derive(RustEmbed)] #[folder = "../../packages/local-web/dist"] pub struct Assets; pub async fn serve_frontend(uri: axum::extract::Path) -> impl IntoResponse { let path = uri.trim_start_matches('/'); serve_file(path).await } pub async fn serve_frontend_root() -> impl IntoResponse { serve_file("index.html").await } async fn serve_file(path: &str) -> impl IntoResponse + use<> { let file = Assets::get(path); match file { Some(content) => { let mime = mime_guess::from_path(path).first_or_octet_stream(); Response::builder() .status(StatusCode::OK) .header( header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()).unwrap(), ) .body(Body::from(content.data.into_owned())) .unwrap() } None => { // For SPA routing, serve index.html for unknown routes if let Some(index) = Assets::get("index.html") { Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, HeaderValue::from_static("text/html")) .body(Body::from(index.data.into_owned())) .unwrap() } else { Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("404 Not Found")) .unwrap() } } } } ================================================ FILE: crates/server/src/routes/health.rs ================================================ use axum::response::Json; use utils::response::ApiResponse; pub async fn health_check() -> Json> { Json(ApiResponse::success("OK".to_string())) } ================================================ FILE: crates/server/src/routes/migration.rs ================================================ use axum::{ Router, extract::{Json, State}, response::Json as ResponseJson, routing::{get, post}, }; use db::models::project::Project; use deployment::Deployment; use services::services::migration::{MigrationRequest, MigrationResponse, MigrationService}; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new() .route("/migration/start", post(start_migration)) .route("/migration/projects", get(list_projects)) } async fn start_migration( State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let remote_client = deployment.remote_client()?; let sqlite_pool = deployment.db().pool.clone(); let service = MigrationService::new(sqlite_pool, remote_client); let project_ids = request.project_id_set(); let report = service .run_migration(request.organization_id, project_ids) .await?; Ok(ResponseJson(ApiResponse::success(MigrationResponse { report, }))) } async fn list_projects( State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let projects = Project::find_all(pool).await?; Ok(ResponseJson(ApiResponse::success(projects))) } ================================================ FILE: crates/server/src/routes/mod.rs ================================================ use axum::{ Router, routing::{IntoMakeService, get}, }; use tower_http::{compression::CompressionLayer, validate_request::ValidateRequestHeaderLayer}; use crate::{DeploymentImpl, middleware}; pub mod approvals; pub mod config; pub mod containers; pub mod filesystem; // pub mod github; pub mod attachments; pub mod events; pub mod execution_processes; pub mod frontend; pub mod health; pub mod migration; pub mod oauth; pub mod organizations; pub mod relay_auth; pub mod relay_ws; pub mod releases; pub mod remote; pub mod repo; pub mod scratch; pub mod search; pub mod sessions; pub mod tags; pub mod terminal; pub mod workspaces; pub fn router(deployment: DeploymentImpl) -> IntoMakeService { let relay_signed_routes = Router::new() .route("/health", get(health::health_check)) .merge(config::router()) .merge(containers::router(&deployment)) .merge(workspaces::router(&deployment)) .merge(execution_processes::router(&deployment)) .merge(tags::router(&deployment)) .merge(oauth::router()) .merge(organizations::router()) .merge(filesystem::router()) .merge(repo::router()) .merge(events::router(&deployment)) .merge(approvals::router()) .merge(scratch::router(&deployment)) .merge(search::router(&deployment)) .merge(releases::router()) .merge(migration::router()) .merge(sessions::router(&deployment)) .merge(terminal::router()) .nest("/remote", remote::router()) .nest("/attachments", attachments::routes()) .layer(axum::middleware::from_fn_with_state( deployment.clone(), middleware::sign_relay_response, )) .layer(axum::middleware::from_fn_with_state( deployment.clone(), middleware::require_relay_request_signature, )) .with_state(deployment.clone()); let api_routes = Router::new() .merge(relay_auth::router()) .merge(relay_signed_routes) .layer(ValidateRequestHeaderLayer::custom( middleware::validate_origin, )) .layer(axum::middleware::from_fn(middleware::log_server_errors)) .with_state(deployment); Router::new() .route("/", get(frontend::serve_frontend_root)) .route("/{*path}", get(frontend::serve_frontend)) .nest("/api", api_routes) .layer(CompressionLayer::new()) .into_make_service() } ================================================ FILE: crates/server/src/routes/oauth.rs ================================================ use api_types::{HandoffInitRequest, HandoffRedeemRequest, StatusResponse}; use axum::{ Router, extract::{Json, Query, State}, http::{Response, StatusCode}, response::Json as ResponseJson, routing::{get, post}, }; use chrono::{DateTime, Utc}; use deployment::Deployment; use rand::{Rng, distributions::Alphanumeric}; use serde::{Deserialize, Serialize}; use services::services::{ config::save_config_to_file, oauth_credentials::Credentials, remote_sync, }; use sha2::{Digest, Sha256}; use ts_rs::TS; use utils::{assets::config_path, jwt::extract_expiration, response::ApiResponse}; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError, tunnel}; /// Base64-encoded 32x32 app icon (from `crates/tauri-app/icons/32x32.png`). const APP_ICON_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAASAAAAABAAABIAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAA5NwgRAAAACXBIWXMAACxLAAAsSwGlPZapAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkZpZ21hPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoE/1zIAAAFUElEQVRYCe1Vy2tcVRj/3cfcmZt5ZPKibRK1bVrpg1YplIq0vhAqVkEqVVxapNpF/wGhO3cuXCmI4tpSXIkLi9KHm1KktVXsC5omNWk6ycRkJjN35r6Ov+/eO5mZDoIbySaHOXPvPb/vfN/vfK+jlT7eFQLQONdkmFBrZ1xOLATWdKwTWPdArwcCP05KjZWpG70JGgaASjJXcJHrXDNkT0dVd2Fmj75uApoOY2RrZFj5DYSLM9SltzfRsF7YCC2T45pCILjfhF4cg2bZXAoRlB/EhIQYhz4wDi2VIRYkGNtOggneJiCb7QIGTn4DIz8Ed/YOlr48Ad1ZFrloBG4TmcOnkNv/pthH+auT8G5eQv/R07B3PA/lNlD+4jgw82ck7wcBiu98gszEPoSNFZQ/fx/a3N1EW/zQoz4gHuX0xLuGBc20EGb6Ee58ld6mBxI8CBR8zYxwGCbC7Yfg5TYgUFq0JnuDHa9A2QPQGErfcRBAjzA9W4z0hekCPUQvJDo7PBCTaMXXr1fQqK3AZjwNEZYRbYo/FA3UF0vUJQTjtdB34SwtIB0qWAzLwLEPkd64BcvTtzF74SysxhL6eAC9pY8quzwQGYgsSZg0gi2jjz0TGV1i2aFMoq4JGc+Fmyogd/BdpPpH0Jy5A0z9hky2wFQQN7f19XogUb76aAnLQoexCH/sW9N1pArDTOTNCPMjzMmQOerQhxr6RsZRu/IDcmEtTuxkbw+BVZ3y0jlbBLoEOmT4qmey2HTkBLTXP+AXzeoabn59GurRPQw/sYUhWIRpWV0HaROQS5mblBbXfuD7CFwXCKRsCIlrmyxNxjkaZhohk49/FAkh2+XEhpmKYOHp+x6Kg8NSa/BKD2BL31g9QCTWUYZivFHH3JVzyD+9D5mhURRfOAbv0S1gfgoqN4js66dgbtuH5UczmP35DIylOZgkHJIkCwQBCd47/x28yiLSgxsx/uJbyB94A7VfzkLdvQw9zwp4jEA7CUnAbNZRu/w9nPlZGIUh2LsPQu09DJXJw2U5mofeQ2psGxoPJ+H9cRFW2o5OHbLbBSThOTU4V3+CunMZ7uR1klLom9iL7P4jSG99lgy7E1DItAnwI2WaMCev8dTTaDp1ePUVONkRBJt2QPVvgN9w0KxWGZom+kZJ5Pp5YImlyFAEVC5hy45vg1Vn85qfhlNZhks9DhuMO7qbUZSQSazas50DssiyKsBD+eIZrNQdjL38NorPHQEOvBZt0llG97/9DM0bFzC4fQ8KTpn6aFiyXQjI9DzYzt/wGLZbp4/iyY8+RWH7M/CHR+FOXYe5MEVdJMKfDD1i1GLFp8HLJFudg1GZh2JHZPECdj/Qx86om8gV+jEwsZv3xBw3M9OpjDcEFDFlpHhKfjHZ8kaI8WYJRuhB8S5Qdh6NnS8hsHiPMHFbnmh7ICZEZjoyThW1qz9ifvMeOLOTZCwMaUya0/x9pGVO34Bh25Gu6t1rWCwvwJ+bhFWrROcSYiaJVG9fQ/nhXwiYN2alBIudMKqzJAy9BAiYzIV8bQFLv56DqpTZOoM4eXk6c2gTLJZanxWXm0FS4e+X4A09Ra+VYI5NsNuRsOjhX8hw+cVRGNUFWGNb250wObBWOr5LuPQMOXPFzCFkvUvHjTTSCyHdl3GryCrpBwIo1LUUHCtP1zMUvCNybgVWRFkwCw1i0pQUkzRPLKW1TfZ6QGxxyD1QoLBqRp9df9EdELPiugZbeUjXy5GMUBIi8SAWusQWurG2/c5GlOzpeOhCI8nWjuX4tUOJ9HoJxer4j5jI/6sHVpX9zy/rBNY9sOYe+AcCwIEbenVoBQAAAABJRU5ErkJggg=="; /// Shared CSS styles for standalone OAuth HTML pages (success & error). /// Colors and typography match the app's design system (light mode defaults /// from `packages/web-core/src/app/styles/new/index.css`). const AUTH_PAGE_STYLES: &str = r#""#; /// Response from GET /api/auth/token - returns the current access token #[derive(Debug, Serialize, TS)] pub struct TokenResponse { pub access_token: String, pub expires_at: Option>, } /// Response from GET /api/auth/user - returns the current user ID #[derive(Debug, Serialize, TS)] pub struct CurrentUserResponse { pub user_id: String, } pub fn router() -> Router { Router::new() .route("/auth/handoff/init", post(handoff_init)) .route("/auth/handoff/complete", get(handoff_complete)) .route("/auth/logout", post(logout)) .route("/auth/status", get(status)) .route("/auth/token", get(get_token)) .route("/auth/user", get(get_current_user)) } #[derive(Debug, Deserialize)] struct HandoffInitPayload { provider: String, return_to: String, } #[derive(Debug, Serialize)] struct HandoffInitResponseBody { handoff_id: Uuid, authorize_url: String, } async fn handoff_init( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let app_verifier = generate_secret(); let app_challenge = hash_sha256_hex(&app_verifier); let request = HandoffInitRequest { provider: payload.provider.clone(), return_to: payload.return_to.clone(), app_challenge, }; let response = client.handoff_init(&request).await?; deployment .store_oauth_handoff(response.handoff_id, payload.provider, app_verifier) .await; Ok(ResponseJson(ApiResponse::success( HandoffInitResponseBody { handoff_id: response.handoff_id, authorize_url: response.authorize_url, }, ))) } #[derive(Debug, Deserialize)] struct HandoffCompleteQuery { handoff_id: Uuid, #[serde(default)] app_code: Option, #[serde(default)] error: Option, /// When set to "desktop", the callback page will not auto-close so the user /// can see the success message (e.g. when opened from the Tauri desktop app). #[serde(default)] source: Option, } async fn handoff_complete( State(deployment): State, Query(query): Query, ) -> Result, ApiError> { if let Some(error) = query.error { return Ok(simple_html_response( StatusCode::BAD_REQUEST, format!("OAuth authorization failed: {error}"), )); } let Some(app_code) = query.app_code.clone() else { return Ok(simple_html_response( StatusCode::BAD_REQUEST, "Missing app_code in callback".to_string(), )); }; let (provider, app_verifier) = match deployment.take_oauth_handoff(&query.handoff_id).await { Some(state) => state, None => { tracing::warn!( handoff_id = %query.handoff_id, "received callback for unknown handoff" ); return Ok(simple_html_response( StatusCode::BAD_REQUEST, "OAuth handoff not found or already completed".to_string(), )); } }; let client = deployment.remote_client()?; let redeem_request = HandoffRedeemRequest { handoff_id: query.handoff_id, app_code, app_verifier, }; let redeem = client.handoff_redeem(&redeem_request).await?; let expires_at = extract_expiration(&redeem.access_token) .map_err(|err| ApiError::BadRequest(format!("Invalid access token: {err}")))?; let credentials = Credentials { access_token: Some(redeem.access_token.clone()), refresh_token: redeem.refresh_token.clone(), expires_at: Some(expires_at), }; deployment .auth_context() .save_credentials(&credentials) .await .map_err(|e| { tracing::error!(?e, "failed to save credentials"); ApiError::Io(e) })?; // Enable analytics automatically on login if not already enabled let config_guard = deployment.config().read().await; if !config_guard.analytics_enabled { let mut new_config = config_guard.clone(); drop(config_guard); // Release read lock before acquiring write lock new_config.analytics_enabled = true; // Save updated config to disk let config_path = config_path(); if let Err(e) = save_config_to_file(&new_config, &config_path).await { tracing::warn!( ?e, "failed to save config after enabling analytics on login" ); } else { // Update in-memory config let mut config = deployment.config().write().await; *config = new_config; drop(config); tracing::info!("analytics automatically enabled after successful login"); // Track analytics_session_start event if let Some(analytics) = deployment.analytics() { analytics.track_event( deployment.user_id(), "analytics_session_start", Some(serde_json::json!({})), ); } } } else { drop(config_guard); } // Fetch and cache the user's profile let _ = deployment.get_login_status().await; // Sync all linked workspace states and PRs to remote in the background if let Ok(client) = deployment.remote_client() { let pool = deployment.db().pool.clone(); let git = deployment.git().clone(); tokio::spawn(async move { remote_sync::sync_all_linked_workspaces(&client, &pool, &git).await; }); } if let Some(profile) = deployment.auth_context().cached_profile().await && let Some(analytics) = deployment.analytics() { analytics.track_event( deployment.user_id(), "$identify", Some(serde_json::json!({ "email": profile.email, })), ); // Merge the local machine-based ID with the remote user UUID so all // events (local frontend, local backend, remote backend) resolve to // the same PostHog person. Uses $merge_dangerously because // $create_alias is blocked by PostHog's safeguard when the machine // ID was already used as a distinct_id in a prior identify call. analytics.track_event( &profile.user_id.to_string(), "$merge_dangerously", Some(serde_json::json!({ "alias": deployment.user_id(), })), ); } // Start relay if enabled let relay_deployment = deployment.clone(); tokio::spawn(async move { tunnel::spawn_relay(&relay_deployment).await; }); let is_desktop = query.source.as_deref() == Some("desktop"); Ok(close_window_response( format!("Signed in with {provider}. You can return to the app."), is_desktop, )) } async fn logout(State(deployment): State) -> Result { let auth_context = deployment.auth_context(); if let Ok(client) = deployment.remote_client() { let _ = client.logout().await; } auth_context.clear_credentials().await.map_err(|e| { tracing::error!(?e, "failed to clear credentials"); ApiError::Io(e) })?; auth_context.clear_profile().await; tunnel::stop_relay(&deployment).await; Ok(StatusCode::NO_CONTENT) } async fn status( State(deployment): State, ) -> Result>, ApiError> { use api_types::LoginStatus; match deployment.get_login_status().await { LoginStatus::LoggedOut => Ok(ResponseJson(ApiResponse::success(StatusResponse { logged_in: false, profile: None, degraded: None, }))), LoginStatus::LoggedIn { profile } => { Ok(ResponseJson(ApiResponse::success(StatusResponse { logged_in: true, profile: Some(profile), degraded: None, }))) } } } /// Returns the current access token (auto-refreshes if needed) async fn get_token( State(deployment): State, ) -> Result>, ApiError> { let remote_client = deployment.remote_client()?; // This will auto-refresh the token if expired let access_token = remote_client .access_token() .await .map_err(|_| ApiError::Unauthorized)?; let creds = deployment.auth_context().get_credentials().await; let expires_at = creds.and_then(|c| c.expires_at); Ok(ResponseJson(ApiResponse::success(TokenResponse { access_token, expires_at, }))) } async fn get_current_user( State(deployment): State, ) -> Result>, ApiError> { let remote_client = deployment.remote_client()?; // Get the access token from remote client let access_token = remote_client .access_token() .await .map_err(|_| ApiError::Unauthorized)?; // Extract user ID from the JWT token's 'sub' claim let user_id = utils::jwt::extract_subject(&access_token) .map_err(|e| { tracing::error!("Failed to extract user ID from token: {}", e); ApiError::Unauthorized })? .to_string(); Ok(ResponseJson(ApiResponse::success(CurrentUserResponse { user_id, }))) } fn generate_secret() -> String { rand::thread_rng() .sample_iter(&Alphanumeric) .take(64) .map(char::from) .collect() } fn hash_sha256_hex(input: &str) -> String { let mut output = String::with_capacity(64); let digest = Sha256::digest(input.as_bytes()); for byte in digest { use std::fmt::Write; let _ = write!(output, "{:02x}", byte); } output } fn simple_html_response(status: StatusCode, message: String) -> Response { let body = format!( r#" OAuth Error {AUTH_PAGE_STYLES}

{message}

Please close this tab and try again.

"# ); Response::builder() .status(status) .header("content-type", "text/html; charset=utf-8") .body(body) .unwrap() } fn close_window_response(message: String, skip_auto_close: bool) -> Response { let script = if skip_auto_close { "" // Desktop app: leave the tab open so the user sees the message } else { "" }; let body = format!( r#" Authentication Complete {script} {AUTH_PAGE_STYLES}

{message}

You can close this tab and return to the app.

"# ); Response::builder() .status(StatusCode::OK) .header("content-type", "text/html; charset=utf-8") .body(body) .unwrap() } ================================================ FILE: crates/server/src/routes/organizations.rs ================================================ use api_types::{ AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse, CreateOrganizationRequest, CreateOrganizationResponse, GetInvitationResponse, GetOrganizationResponse, ListInvitationsResponse, ListMembersResponse, ListOrganizationsResponse, Organization, RevokeInvitationRequest, UpdateMemberRoleRequest, UpdateMemberRoleResponse, UpdateOrganizationRequest, }; use axum::{ Router, extract::{Json, Path, State}, http::StatusCode, response::Json as ResponseJson, routing::{delete, get, patch, post}, }; use deployment::Deployment; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new() .route("/organizations", get(list_organizations)) .route("/organizations", post(create_organization)) .route("/organizations/{id}", get(get_organization)) .route("/organizations/{id}", patch(update_organization)) .route("/organizations/{id}", delete(delete_organization)) .route( "/organizations/{org_id}/invitations", post(create_invitation), ) .route("/organizations/{org_id}/invitations", get(list_invitations)) .route( "/organizations/{org_id}/invitations/revoke", post(revoke_invitation), ) .route("/invitations/{token}", get(get_invitation)) .route("/invitations/{token}/accept", post(accept_invitation)) .route("/organizations/{org_id}/members", get(list_members)) .route( "/organizations/{org_id}/members/{user_id}", delete(remove_member), ) .route( "/organizations/{org_id}/members/{user_id}/role", patch(update_member_role), ) } async fn list_organizations( State(deployment): State, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_organizations().await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_organization( State(deployment): State, Path(id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_organization(id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn create_organization( State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.create_organization(&request).await?; deployment .track_if_analytics_allowed( "organization_created", serde_json::json!({ "org_id": response.organization.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(response))) } async fn update_organization( State(deployment): State, Path(id): Path, Json(request): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.update_organization(id, &request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn delete_organization( State(deployment): State, Path(id): Path, ) -> Result { let client = deployment.remote_client()?; client.delete_organization(id).await?; Ok(StatusCode::NO_CONTENT) } async fn create_invitation( State(deployment): State, Path(org_id): Path, Json(request): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.create_invitation(org_id, &request).await?; deployment .track_if_analytics_allowed( "invitation_created", serde_json::json!({ "invitation_id": response.invitation.id.to_string(), "org_id": org_id.to_string(), "role": response.invitation.role, }), ) .await; Ok(ResponseJson(ApiResponse::success(response))) } async fn list_invitations( State(deployment): State, Path(org_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_invitations(org_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_invitation( State(deployment): State, Path(token): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_invitation(&token).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn revoke_invitation( State(deployment): State, Path(org_id): Path, Json(payload): Json, ) -> Result { let client = deployment.remote_client()?; client .revoke_invitation(org_id, payload.invitation_id) .await?; Ok(StatusCode::NO_CONTENT) } async fn accept_invitation( State(deployment): State, Path(invitation_token): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.accept_invitation(&invitation_token).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn list_members( State(deployment): State, Path(org_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_members(org_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn remove_member( State(deployment): State, Path((org_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { let client = deployment.remote_client()?; client.remove_member(org_id, user_id).await?; Ok(StatusCode::NO_CONTENT) } async fn update_member_role( State(deployment): State, Path((org_id, user_id)): Path<(Uuid, Uuid)>, Json(request): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.update_member_role(org_id, user_id, &request).await?; Ok(ResponseJson(ApiResponse::success(response))) } ================================================ FILE: crates/server/src/routes/relay_auth.rs ================================================ use std::time::Duration; use axum::{ Json, Router, extract::{Json as ExtractJson, Path, State}, http::HeaderMap, routing::{delete, get, post}, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use deployment::Deployment; use serde::{Deserialize, Serialize}; use trusted_key_auth::{ key_confirmation::{build_server_proof, verify_client_proof}, refresh::{build_refresh_message, validate_refresh_timestamp, verify_refresh_signature}, spake2::{generate_one_time_code, start_spake2_enrollment}, trusted_keys::{TrustedRelayClient, parse_public_key_base64}, }; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); const GENERATE_CODE_GLOBAL_LIMIT: usize = 5; const SPAKE2_START_GLOBAL_LIMIT: usize = 30; const SIGNING_SESSION_REFRESH_GLOBAL_LIMIT: usize = 30; const RELAY_HEADER: &str = "x-vk-relayed"; #[derive(Debug, Serialize)] struct GenerateEnrollmentCodeResponse { enrollment_code: String, } #[derive(Debug, Deserialize, TS)] pub struct StartSpake2EnrollmentRequest { enrollment_code: String, client_message_b64: String, } #[derive(Debug, Serialize, TS)] pub struct StartSpake2EnrollmentResponse { enrollment_id: Uuid, server_message_b64: String, } #[derive(Debug, Deserialize, TS)] pub struct FinishSpake2EnrollmentRequest { enrollment_id: Uuid, client_id: Uuid, client_name: String, client_browser: String, client_os: String, client_device: String, public_key_b64: String, client_proof_b64: String, } #[derive(Debug, Serialize, TS)] pub struct FinishSpake2EnrollmentResponse { signing_session_id: Uuid, server_public_key_b64: String, server_proof_b64: String, } #[derive(Debug, Serialize, TS)] pub struct RelayPairedClient { client_id: Uuid, client_name: String, client_browser: String, client_os: String, client_device: String, } #[derive(Debug, Serialize, TS)] pub struct ListRelayPairedClientsResponse { clients: Vec, } #[derive(Debug, Serialize, TS)] pub struct RemoveRelayPairedClientResponse { removed: bool, } #[derive(Debug, Deserialize, TS)] pub struct RefreshRelaySigningSessionRequest { client_id: Uuid, timestamp: i64, nonce: String, signature_b64: String, } #[derive(Debug, Serialize, TS)] pub struct RefreshRelaySigningSessionResponse { signing_session_id: Uuid, } pub fn router() -> Router { Router::new() .route( "/relay-auth/enrollment-code", post(generate_enrollment_code), ) .route("/relay-auth/clients", get(list_relay_paired_clients)) .route( "/relay-auth/clients/{client_id}", delete(remove_relay_paired_client), ) .route( "/relay-auth/spake2/start", post(start_spake2_enrollment_route), ) .route("/relay-auth/spake2/finish", post(finish_spake2_enrollment)) .route( "/relay-auth/signing-session/refresh", post(refresh_relay_signing_session), ) } async fn generate_enrollment_code( State(deployment): State, headers: HeaderMap, ) -> Result>, ApiError> { if is_relay_request(&headers) { return Err(ApiError::Forbidden( "Enrollment code cannot be fetched over relay.".to_string(), )); } deployment .trusted_key_auth() .enforce_rate_limit( "relay-auth:code-generation:global", GENERATE_CODE_GLOBAL_LIMIT, RATE_LIMIT_WINDOW, ) .await?; let enrollment_code = deployment .trusted_key_auth() .get_or_set_enrollment_code(generate_one_time_code()) .await; Ok(Json(ApiResponse::success(GenerateEnrollmentCodeResponse { enrollment_code, }))) } async fn start_spake2_enrollment_route( State(deployment): State, ExtractJson(payload): ExtractJson, ) -> Result>, ApiError> { deployment .trusted_key_auth() .enforce_rate_limit( "relay-auth:spake2-start:global", SPAKE2_START_GLOBAL_LIMIT, RATE_LIMIT_WINDOW, ) .await?; let spake2_start = start_spake2_enrollment(&payload.enrollment_code, &payload.client_message_b64)?; if !deployment .trusted_key_auth() .consume_enrollment_code(&spake2_start.enrollment_code) .await { return Err(ApiError::Unauthorized); } let enrollment_id = Uuid::new_v4(); deployment .trusted_key_auth() .store_pake_enrollment(enrollment_id, spake2_start.shared_key) .await; Ok(Json(ApiResponse::success(StartSpake2EnrollmentResponse { enrollment_id, server_message_b64: spake2_start.server_message_b64, }))) } async fn list_relay_paired_clients( State(deployment): State, ) -> Result>, ApiError> { let clients = deployment.trusted_key_auth().list_trusted_clients().await?; let clients = clients .into_iter() .map(|client| RelayPairedClient { client_id: client.client_id, client_name: client.client_name, client_browser: client.client_browser, client_os: client.client_os, client_device: client.client_device, }) .collect(); Ok(Json(ApiResponse::success(ListRelayPairedClientsResponse { clients, }))) } async fn remove_relay_paired_client( State(deployment): State, Path(client_id): Path, ) -> Result>, ApiError> { let removed = deployment .trusted_key_auth() .remove_trusted_client(client_id) .await?; Ok(Json(ApiResponse::success( RemoveRelayPairedClientResponse { removed }, ))) } async fn finish_spake2_enrollment( State(deployment): State, ExtractJson(payload): ExtractJson, ) -> Result>, ApiError> { let Some(shared_key) = deployment .trusted_key_auth() .take_pake_enrollment(&payload.enrollment_id) .await else { return Err(ApiError::Unauthorized); }; let browser_public_key = parse_public_key_base64(&payload.public_key_b64) .map_err(|_| ApiError::BadRequest("Invalid public_key_b64".to_string()))?; let server_public_key = deployment.relay_signing().server_public_key(); let server_public_key_b64 = BASE64_STANDARD.encode(server_public_key.as_bytes()); verify_client_proof( &shared_key, &payload.enrollment_id, browser_public_key.as_bytes(), &payload.client_proof_b64, ) .map_err(|_| ApiError::Unauthorized)?; // Persist the browser's public key so it survives server restarts if let Err(e) = deployment .trusted_key_auth() .persist_trusted_client(TrustedRelayClient { client_id: payload.client_id, client_name: payload.client_name.clone(), client_browser: payload.client_browser.clone(), client_os: payload.client_os.clone(), client_device: payload.client_device.clone(), public_key_b64: payload.public_key_b64.clone(), }) .await { tracing::warn!(?e, "Failed to persist trusted relay client"); } let signing_session_id = deployment .relay_signing() .create_session(browser_public_key) .await; let server_proof_b64 = build_server_proof( &shared_key, &payload.enrollment_id, browser_public_key.as_bytes(), server_public_key.as_bytes(), ) .map_err(|_| ApiError::Unauthorized)?; tracing::info!( enrollment_id = %payload.enrollment_id, client_id = %payload.client_id, signing_session_id = %signing_session_id, public_key_b64 = %BASE64_STANDARD.encode(browser_public_key.as_bytes()), "completed relay PAKE enrollment" ); deployment .track_if_analytics_allowed( "relay_host_paired", serde_json::json!({ "client_id": payload.client_id, "client_browser": payload.client_browser, "client_os": payload.client_os, "client_device": payload.client_device, }), ) .await; Ok(Json(ApiResponse::success(FinishSpake2EnrollmentResponse { signing_session_id, server_public_key_b64, server_proof_b64, }))) } async fn refresh_relay_signing_session( State(deployment): State, ExtractJson(payload): ExtractJson, ) -> Result>, ApiError> { deployment .trusted_key_auth() .enforce_rate_limit( "relay-auth:signing-refresh:global", SIGNING_SESSION_REFRESH_GLOBAL_LIMIT, RATE_LIMIT_WINDOW, ) .await?; let trusted_client = deployment .trusted_key_auth() .find_trusted_client(payload.client_id) .await? .ok_or(ApiError::Unauthorized)?; let browser_public_key = parse_public_key_base64(&trusted_client.public_key_b64) .map_err(|_| ApiError::Unauthorized)?; validate_refresh_timestamp(payload.timestamp)?; deployment .trusted_key_auth() .claim_refresh_nonce(&payload.nonce) .await?; let refresh_message = build_refresh_message(payload.timestamp, &payload.nonce, payload.client_id); verify_refresh_signature( &browser_public_key, &refresh_message, &payload.signature_b64, )?; let signing_session_id = deployment .relay_signing() .create_session(browser_public_key) .await; Ok(Json(ApiResponse::success( RefreshRelaySigningSessionResponse { signing_session_id }, ))) } fn is_relay_request(headers: &HeaderMap) -> bool { headers .get(RELAY_HEADER) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.trim() == "1") } ================================================ FILE: crates/server/src/routes/relay_ws.rs ================================================ use anyhow::Context as _; use axum::{ extract::{ FromRef, FromRequestParts, ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, }, http::request::Parts, response::IntoResponse, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use deployment::Deployment; use futures_util::{Sink, SinkExt, Stream, StreamExt}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{DeploymentImpl, middleware::RelayRequestSignatureContext}; const WS_ENVELOPE_VERSION: u8 = 1; #[derive(Debug, Clone)] pub struct RelayWsSigningState { signing_session_id: Uuid, request_nonce: String, inbound_seq: u64, outbound_seq: u64, } #[derive(Debug, Serialize, Deserialize)] struct RelaySignedWsEnvelope { version: u8, seq: u64, msg_type: RelayWsMessageType, payload_b64: String, signature_b64: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum RelayWsMessageType { Text, Binary, Ping, Pong, Close, } impl RelayWsMessageType { fn as_str(self) -> &'static str { match self { Self::Text => "text", Self::Binary => "binary", Self::Ping => "ping", Self::Pong => "pong", Self::Close => "close", } } } pub fn relay_ws_signing_state( relay_ctx: Option, ) -> Option { relay_ctx.map(|ctx| RelayWsSigningState { signing_session_id: ctx.signing_session_id, request_nonce: ctx.request_nonce, inbound_seq: 0, outbound_seq: 0, }) } pub struct SignedWsUpgrade { ws: WebSocketUpgrade, deployment: DeploymentImpl, relay_signing: Option, } impl FromRequestParts for SignedWsUpgrade where S: Send + Sync, DeploymentImpl: FromRef, { type Rejection = axum::extract::ws::rejection::WebSocketUpgradeRejection; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let ws = WebSocketUpgrade::from_request_parts(parts, state).await?; let deployment = DeploymentImpl::from_ref(state); let relay_ctx = parts .extensions .get::() .cloned(); Ok(Self { ws, deployment, relay_signing: relay_ws_signing_state(relay_ctx), }) } } impl SignedWsUpgrade { pub fn on_upgrade(self, callback: F) -> impl IntoResponse where F: FnOnce(SignedWebSocket) -> Fut + Send + 'static, Fut: std::future::Future + Send + 'static, { let deployment = self.deployment.clone(); let relay_signing = self.relay_signing.clone(); self.ws.on_upgrade(move |socket| async move { let signed_socket = SignedWebSocket { socket, deployment, relay_signing, }; callback(signed_socket).await; }) } } pub struct SignedWebSocket { socket: WebSocket, deployment: DeploymentImpl, relay_signing: Option, } impl SignedWebSocket { pub async fn send(&mut self, message: Message) -> anyhow::Result<()> { send_ws_message( &mut self.socket, &self.deployment, &mut self.relay_signing, message, ) .await } pub async fn recv(&mut self) -> anyhow::Result> { recv_ws_message(&mut self.socket, &self.deployment, &mut self.relay_signing).await } pub async fn close(&mut self) -> anyhow::Result<()> { self.socket.close().await.map_err(anyhow::Error::from) } } pub async fn send_ws_message( sender: &mut S, deployment: &DeploymentImpl, relay_signing: &mut Option, message: Message, ) -> anyhow::Result<()> where S: Sink + Unpin, { let outbound = if let Some(signing) = relay_signing.as_mut() { match message { Message::Text(text) => { let payload = text.as_str().as_bytes().to_vec(); let seq = signing.outbound_seq.saturating_add(1); let envelope = build_signed_envelope( deployment, signing, seq, RelayWsMessageType::Text, payload, ) .await?; signing.outbound_seq = seq; Message::Binary(serde_json::to_vec(&envelope)?.into()) } Message::Binary(payload) => { let seq = signing.outbound_seq.saturating_add(1); let envelope = build_signed_envelope( deployment, signing, seq, RelayWsMessageType::Binary, payload.to_vec(), ) .await?; signing.outbound_seq = seq; Message::Binary(serde_json::to_vec(&envelope)?.into()) } Message::Ping(payload) => { let seq = signing.outbound_seq.saturating_add(1); let envelope = build_signed_envelope( deployment, signing, seq, RelayWsMessageType::Ping, payload.to_vec(), ) .await?; signing.outbound_seq = seq; Message::Binary(serde_json::to_vec(&envelope)?.into()) } Message::Pong(payload) => { let seq = signing.outbound_seq.saturating_add(1); let envelope = build_signed_envelope( deployment, signing, seq, RelayWsMessageType::Pong, payload.to_vec(), ) .await?; signing.outbound_seq = seq; Message::Binary(serde_json::to_vec(&envelope)?.into()) } Message::Close(close_frame) => { let seq = signing.outbound_seq.saturating_add(1); let envelope = build_signed_envelope( deployment, signing, seq, RelayWsMessageType::Close, encode_close_payload(close_frame), ) .await?; signing.outbound_seq = seq; Message::Binary(serde_json::to_vec(&envelope)?.into()) } } } else { message }; sender.send(outbound).await.map_err(anyhow::Error::from) } pub async fn recv_ws_message( receiver: &mut S, deployment: &DeploymentImpl, relay_signing: &mut Option, ) -> anyhow::Result> where S: Stream> + Unpin, { let Some(message_result) = receiver.next().await else { return Ok(None); }; let message = message_result.map_err(anyhow::Error::from)?; let decoded = if let Some(signing) = relay_signing.as_mut() { match message { Message::Text(text) => { decode_signed_envelope(deployment, signing, text.as_str().as_bytes()).await? } Message::Binary(data) => decode_signed_envelope(deployment, signing, &data).await?, Message::Ping(payload) => Message::Ping(payload), Message::Pong(payload) => Message::Pong(payload), Message::Close(close_frame) => Message::Close(close_frame), } } else { message }; Ok(Some(decoded)) } async fn build_signed_envelope( deployment: &DeploymentImpl, signing: &RelayWsSigningState, seq: u64, msg_type: RelayWsMessageType, payload: Vec, ) -> anyhow::Result { let sign_message = ws_signing_input( signing.signing_session_id, &signing.request_nonce, seq, msg_type, &payload, ); let signature_b64 = deployment .relay_signing() .sign_message(signing.signing_session_id, sign_message.as_bytes()) .await .map_err(|error| anyhow::anyhow!("failed to sign relay WS frame: {}", error.as_str()))?; Ok(RelaySignedWsEnvelope { version: WS_ENVELOPE_VERSION, seq, msg_type, payload_b64: BASE64_STANDARD.encode(payload), signature_b64, }) } async fn decode_signed_envelope( deployment: &DeploymentImpl, signing: &mut RelayWsSigningState, raw_message: &[u8], ) -> anyhow::Result { let envelope: RelaySignedWsEnvelope = serde_json::from_slice(raw_message).context("invalid relay WS envelope JSON")?; if envelope.version != WS_ENVELOPE_VERSION { return Err(anyhow::anyhow!("unsupported relay WS envelope version")); } let expected_seq = signing.inbound_seq.saturating_add(1); if envelope.seq != expected_seq { return Err(anyhow::anyhow!( "invalid relay WS sequence: expected {}, got {}", expected_seq, envelope.seq )); } let payload = BASE64_STANDARD .decode(&envelope.payload_b64) .context("invalid relay WS payload")?; let sign_message = ws_signing_input( signing.signing_session_id, &signing.request_nonce, envelope.seq, envelope.msg_type, &payload, ); deployment .relay_signing() .verify_signature( signing.signing_session_id, sign_message.as_bytes(), &envelope.signature_b64, ) .await .map_err(|error| anyhow::anyhow!("invalid relay WS frame signature: {}", error.as_str()))?; signing.inbound_seq = envelope.seq; match envelope.msg_type { RelayWsMessageType::Text => { let text = String::from_utf8(payload).context("invalid UTF-8 text frame")?; Ok(Message::Text(text.into())) } RelayWsMessageType::Binary => Ok(Message::Binary(payload.into())), RelayWsMessageType::Ping => Ok(Message::Ping(payload.into())), RelayWsMessageType::Pong => Ok(Message::Pong(payload.into())), RelayWsMessageType::Close => { let close_frame = decode_close_payload(payload)?; Ok(Message::Close(close_frame)) } } } fn ws_signing_input( signing_session_id: Uuid, request_nonce: &str, seq: u64, msg_type: RelayWsMessageType, payload: &[u8], ) -> String { let payload_hash = BASE64_STANDARD.encode(Sha256::digest(payload)); format!( "v1|{signing_session_id}|{request_nonce}|{seq}|{msg_type}|{payload_hash}", msg_type = msg_type.as_str() ) } fn encode_close_payload(close_frame: Option) -> Vec { if let Some(close_frame) = close_frame { let code: u16 = close_frame.code; let reason = close_frame.reason.to_string(); let mut payload = Vec::with_capacity(2 + reason.len()); payload.extend_from_slice(&code.to_be_bytes()); payload.extend_from_slice(reason.as_bytes()); payload } else { Vec::new() } } fn decode_close_payload(payload: Vec) -> anyhow::Result> { if payload.is_empty() { return Ok(None); } if payload.len() < 2 { return Err(anyhow::anyhow!("invalid close payload")); } let code = u16::from_be_bytes([payload[0], payload[1]]); let reason = String::from_utf8(payload[2..].to_vec()).context("invalid UTF-8 close frame reason")?; Ok(Some(CloseFrame { code, reason: reason.into(), })) } ================================================ FILE: crates/server/src/routes/releases.rs ================================================ use std::{ sync::OnceLock, time::{Duration, Instant}, }; use axum::{Router, response::Json as ResponseJson, routing::get}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::DeploymentImpl; const CACHE_TTL: Duration = Duration::from_secs(15 * 60); const GITHUB_API_URL: &str = "https://api.github.com/repos/BloopAI/vibe-kanban/releases"; type ReleasesCache = RwLock, Instant)>>; static HTTP_CLIENT: OnceLock = OnceLock::new(); static RELEASES_CACHE: OnceLock = OnceLock::new(); fn client() -> &'static Client { HTTP_CLIENT.get_or_init(|| { Client::builder() .user_agent("vibe-kanban-server") .build() .expect("failed to build releases HTTP client") }) } fn cache() -> &'static RwLock, Instant)>> { RELEASES_CACHE.get_or_init(|| RwLock::new(None)) } pub fn router() -> Router { Router::new().route("/releases", get(get_releases)) } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GitHubRelease { pub name: String, pub tag_name: String, pub published_at: String, pub body: String, } #[derive(Debug, Serialize)] struct ReleasesResponse { releases: Vec, } #[derive(Deserialize)] struct GitHubReleaseRaw { tag_name: String, name: Option, published_at: Option, body: Option, prerelease: bool, } async fn get_releases() -> ResponseJson> { // Check cache { let guard = cache().read().await; if let Some((releases, fetched_at)) = guard.as_ref() && fetched_at.elapsed() < CACHE_TTL { return ResponseJson(utils::response::ApiResponse::success(ReleasesResponse { releases: releases.clone(), })); } } // Fetch from GitHub match fetch_releases().await { Ok(releases) => { // Update cache { let mut guard = cache().write().await; *guard = Some((releases.clone(), Instant::now())); } ResponseJson(utils::response::ApiResponse::success(ReleasesResponse { releases, })) } Err(e) => { tracing::warn!("Failed to fetch GitHub releases: {}", e); // Return stale cache if available let guard = cache().read().await; if let Some((releases, _)) = guard.as_ref() { return ResponseJson(utils::response::ApiResponse::success(ReleasesResponse { releases: releases.clone(), })); } drop(guard); ResponseJson(utils::response::ApiResponse::error(&format!( "Failed to fetch releases: {}", e ))) } } } async fn fetch_releases() -> Result, reqwest::Error> { let response = client() .get(GITHUB_API_URL) .query(&[("per_page", "20")]) .header("Accept", "application/vnd.github+json") .send() .await? .error_for_status()?; let all_releases: Vec = response.json().await?; Ok(all_releases .into_iter() .filter(|r| { !r.prerelease && !r.tag_name.starts_with("remote-") && !r.tag_name.starts_with("relay-") }) .map(|r| GitHubRelease { name: r.name.unwrap_or_else(|| r.tag_name.clone()), tag_name: r.tag_name, published_at: r.published_at.unwrap_or_default(), body: r.body.unwrap_or_default(), }) .collect()) } ================================================ FILE: crates/server/src/routes/remote/issue_assignees.rs ================================================ use api_types::{ CreateIssueAssigneeRequest, IssueAssignee, ListIssueAssigneesResponse, MutationResponse, }; use axum::{ Router, extract::{Json, Path, Query, State}, response::Json as ResponseJson, routing::get, }; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListIssueAssigneesQuery { pub issue_id: Uuid, } pub fn router() -> Router { Router::new() .route( "/issue-assignees", get(list_issue_assignees).post(create_issue_assignee), ) .route( "/issue-assignees/{issue_assignee_id}", get(get_issue_assignee).delete(delete_issue_assignee), ) } async fn list_issue_assignees( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_issue_assignees(query.issue_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_issue_assignee( State(deployment): State, Path(issue_assignee_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_issue_assignee(issue_assignee_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn create_issue_assignee( State(deployment): State, Json(request): Json, ) -> Result>>, ApiError> { let client = deployment.remote_client()?; let response = client.create_issue_assignee(&request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn delete_issue_assignee( State(deployment): State, Path(issue_assignee_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; client.delete_issue_assignee(issue_assignee_id).await?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/remote/issue_relationships.rs ================================================ use api_types::{ CreateIssueRelationshipRequest, IssueRelationship, ListIssueRelationshipsQuery, ListIssueRelationshipsResponse, MutationResponse, }; use axum::{ Router, extract::{Json, Path, Query, State}, response::Json as ResponseJson, routing::get, }; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new() .route( "/issue-relationships", get(list_issue_relationships).post(create_issue_relationship), ) .route( "/issue-relationships/{relationship_id}", axum::routing::delete(delete_issue_relationship), ) } async fn list_issue_relationships( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_issue_relationships(query.issue_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn create_issue_relationship( State(deployment): State, Json(request): Json, ) -> Result>>, ApiError> { let client = deployment.remote_client()?; let response = client.create_issue_relationship(&request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn delete_issue_relationship( State(deployment): State, Path(relationship_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; client.delete_issue_relationship(relationship_id).await?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/remote/issue_tags.rs ================================================ use api_types::{CreateIssueTagRequest, IssueTag, ListIssueTagsResponse, MutationResponse}; use axum::{ Router, extract::{Json, Path, Query, State}, response::Json as ResponseJson, routing::get, }; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListIssueTagsQuery { pub issue_id: Uuid, } pub fn router() -> Router { Router::new() .route("/issue-tags", get(list_issue_tags).post(create_issue_tag)) .route( "/issue-tags/{issue_tag_id}", get(get_issue_tag).delete(delete_issue_tag), ) } async fn list_issue_tags( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_issue_tags(query.issue_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_issue_tag( State(deployment): State, Path(issue_tag_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_issue_tag(issue_tag_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn create_issue_tag( State(deployment): State, Json(request): Json, ) -> Result>>, ApiError> { let client = deployment.remote_client()?; let response = client.create_issue_tag(&request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn delete_issue_tag( State(deployment): State, Path(issue_tag_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; client.delete_issue_tag(issue_tag_id).await?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/remote/issues.rs ================================================ use api_types::{ CreateIssueRequest, Issue, ListIssuesQuery, ListIssuesResponse, MutationResponse, SearchIssuesRequest, UpdateIssueRequest, }; use axum::{ Router, extract::{Json, Path, Query, State}, response::Json as ResponseJson, routing::{get, post}, }; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new() .route("/issues", get(list_issues).post(create_issue)) .route("/issues/search", post(search_issues)) .route( "/issues/{issue_id}", get(get_issue).patch(update_issue).delete(delete_issue), ) } async fn list_issues( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_issues(query.project_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn search_issues( State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.search_issues(&request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_issue( State(deployment): State, Path(issue_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_issue(issue_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn create_issue( State(deployment): State, Json(request): Json, ) -> Result>>, ApiError> { let client = deployment.remote_client()?; let response = client.create_issue(&request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn update_issue( State(deployment): State, Path(issue_id): Path, Json(request): Json, ) -> Result>>, ApiError> { let client = deployment.remote_client()?; let response = client.update_issue(issue_id, &request).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn delete_issue( State(deployment): State, Path(issue_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; client.delete_issue(issue_id).await?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/remote/mod.rs ================================================ use axum::Router; use crate::DeploymentImpl; mod issue_assignees; mod issue_relationships; mod issue_tags; mod issues; mod project_statuses; mod projects; mod pull_requests; mod tags; mod workspaces; pub fn router() -> Router { Router::new() .merge(issue_assignees::router()) .merge(issue_relationships::router()) .merge(issue_tags::router()) .merge(issues::router()) .merge(projects::router()) .merge(project_statuses::router()) .merge(pull_requests::router()) .merge(tags::router()) .merge(workspaces::router()) } ================================================ FILE: crates/server/src/routes/remote/project_statuses.rs ================================================ use api_types::ListProjectStatusesResponse; use axum::{ Router, extract::{Query, State}, response::Json as ResponseJson, routing::get, }; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListProjectStatusesQuery { pub project_id: Uuid, } pub fn router() -> Router { Router::new().route("/project-statuses", get(list_project_statuses)) } async fn list_project_statuses( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_project_statuses(query.project_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } ================================================ FILE: crates/server/src/routes/remote/projects.rs ================================================ use api_types::{ListProjectsResponse, Project}; use axum::{ Router, extract::{Path, Query, State}, response::Json as ResponseJson, routing::get, }; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListRemoteProjectsQuery { pub organization_id: Uuid, } pub fn router() -> Router { Router::new() .route("/projects", get(list_remote_projects)) .route("/projects/{project_id}", get(get_remote_project)) } async fn list_remote_projects( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_remote_projects(query.organization_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_remote_project( State(deployment): State, Path(project_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let project = client.get_remote_project(project_id).await?; Ok(ResponseJson(ApiResponse::success(project))) } ================================================ FILE: crates/server/src/routes/remote/pull_requests.rs ================================================ use api_types::{ListPullRequestsQuery, ListPullRequestsResponse}; use axum::{ Router, extract::{Query, State}, response::Json as ResponseJson, routing::get, }; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new().route("/pull-requests", get(list_pull_requests)) } async fn list_pull_requests( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_pull_requests(query.issue_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } ================================================ FILE: crates/server/src/routes/remote/tags.rs ================================================ use api_types::{ListTagsResponse, Tag}; use axum::{ Router, extract::{Path, Query, State}, response::Json as ResponseJson, routing::get, }; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct ListTagsQuery { pub project_id: Uuid, } pub fn router() -> Router { Router::new() .route("/tags", get(list_tags)) .route("/tags/{tag_id}", get(get_tag)) } async fn list_tags( State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.list_tags(query.project_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } async fn get_tag( State(deployment): State, Path(tag_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let response = client.get_tag(tag_id).await?; Ok(ResponseJson(ApiResponse::success(response))) } ================================================ FILE: crates/server/src/routes/remote/workspaces.rs ================================================ use api_types::Workspace; use axum::{ Router, extract::{Path, State}, response::Json as ResponseJson, routing::get, }; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new().route( "/workspaces/by-local-id/{local_workspace_id}", get(get_workspace_by_local_id), ) } async fn get_workspace_by_local_id( State(deployment): State, Path(local_workspace_id): Path, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let workspace = client.get_workspace_by_local_id(local_workspace_id).await?; Ok(ResponseJson(ApiResponse::success(workspace))) } ================================================ FILE: crates/server/src/routes/repo.rs ================================================ use std::path::PathBuf; use axum::{ Router, extract::{Path, Query, State}, http::StatusCode, response::Json as ResponseJson, routing::{get, post}, }; use db::models::repo::{Repo, SearchResult, UpdateRepo}; use deployment::Deployment; use git::{GitBranch, GitRemote}; use git_host::{GitHostError, GitHostProvider, GitHostService, OpenPrInfo, ProviderKind}; use serde::{Deserialize, Serialize}; use services::services::file_search::SearchQuery; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(serde::Deserialize)] pub struct OpenEditorRequest { pub editor_type: Option, pub git_repo_path: Option, } #[derive(Debug, serde::Serialize, ts_rs::TS)] pub struct OpenEditorResponse { pub url: Option, } #[derive(Debug, Deserialize, TS)] pub struct RegisterRepoRequest { pub path: String, pub display_name: Option, } #[derive(Debug, Deserialize, TS)] pub struct InitRepoRequest { pub parent_path: String, pub folder_name: String, } #[derive(Debug, Deserialize, TS)] pub struct BatchRepoRequest { pub ids: Vec, } pub async fn register_repo( State(deployment): State, ResponseJson(payload): ResponseJson, ) -> Result>, ApiError> { let repo = deployment .repo() .register( &deployment.db().pool, &payload.path, payload.display_name.as_deref(), ) .await?; Ok(ResponseJson(ApiResponse::success(repo))) } pub async fn init_repo( State(deployment): State, ResponseJson(payload): ResponseJson, ) -> Result>, ApiError> { let repo = deployment .repo() .init_repo( &deployment.db().pool, deployment.git(), &payload.parent_path, &payload.folder_name, ) .await?; Ok(ResponseJson(ApiResponse::success(repo))) } pub async fn get_repo_branches( State(deployment): State, Path(repo_id): Path, ) -> Result>>, ApiError> { let repo = deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await?; let branches = deployment.git().get_all_branches(&repo.path)?; Ok(ResponseJson(ApiResponse::success(branches))) } pub async fn get_repo_remotes( State(deployment): State, Path(repo_id): Path, ) -> Result>>, ApiError> { let repo = deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await?; let remotes = deployment.git().list_remotes(&repo.path)?; Ok(ResponseJson(ApiResponse::success(remotes))) } pub async fn get_repos_batch( State(deployment): State, ResponseJson(payload): ResponseJson, ) -> Result>>, ApiError> { let repos = Repo::find_by_ids(&deployment.db().pool, &payload.ids).await?; Ok(ResponseJson(ApiResponse::success(repos))) } pub async fn get_repos( State(deployment): State, ) -> Result>>, ApiError> { let repos = Repo::list_all(&deployment.db().pool).await?; Ok(ResponseJson(ApiResponse::success(repos))) } pub async fn get_recent_repos( State(deployment): State, ) -> Result>>, ApiError> { let repos = Repo::list_by_recent_workspace_usage(&deployment.db().pool).await?; Ok(ResponseJson(ApiResponse::success(repos))) } pub async fn get_repo( State(deployment): State, Path(repo_id): Path, ) -> Result>, ApiError> { let repo = deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await?; Ok(ResponseJson(ApiResponse::success(repo))) } pub async fn update_repo( State(deployment): State, Path(repo_id): Path, ResponseJson(payload): ResponseJson, ) -> Result>, ApiError> { let repo = Repo::update(&deployment.db().pool, repo_id, &payload).await?; Ok(ResponseJson(ApiResponse::success(repo))) } pub async fn open_repo_in_editor( State(deployment): State, Path(repo_id): Path, ResponseJson(payload): ResponseJson>, ) -> Result>, ApiError> { let repo = deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await?; let editor_config = { let config = deployment.config().read().await; let editor_type_str = payload.as_ref().and_then(|req| req.editor_type.as_deref()); config.editor.with_override(editor_type_str) }; match editor_config.open_file(&repo.path).await { Ok(url) => { tracing::info!( "Opened editor for repo {} at path: {}{}", repo_id, repo.path.to_string_lossy(), if url.is_some() { " (remote mode)" } else { "" } ); deployment .track_if_analytics_allowed( "repo_editor_opened", serde_json::json!({ "repo_id": repo_id.to_string(), "editor_type": payload.as_ref().and_then(|req| req.editor_type.as_ref()), "remote_mode": url.is_some(), }), ) .await; Ok(ResponseJson(ApiResponse::success(OpenEditorResponse { url, }))) } Err(e) => { tracing::error!("Failed to open editor for repo {}: {:?}", repo_id, e); Err(ApiError::EditorOpen(e)) } } } pub async fn search_repo( State(deployment): State, Path(repo_id): Path, Query(search_query): Query, ) -> Result>>, StatusCode> { if search_query.q.trim().is_empty() { return Ok(ResponseJson(ApiResponse::error( "Query parameter 'q' is required and cannot be empty", ))); } let repo = match deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await { Ok(repo) => repo, Err(e) => { tracing::error!("Failed to get repo {}: {}", repo_id, e); return Err(StatusCode::NOT_FOUND); } }; match deployment .file_search_cache() .search_repo(&repo.path, &search_query.q, search_query.mode) .await { Ok(results) => Ok(ResponseJson(ApiResponse::success(results))), Err(e) => { tracing::error!("Failed to search files in repo {}: {}", repo_id, e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum ListPrsError { CliNotInstalled { provider: ProviderKind }, AuthFailed { message: String }, UnsupportedProvider, } #[derive(Debug, Deserialize)] pub struct ListPrsQuery { pub remote: Option, } pub async fn list_open_prs( State(deployment): State, Path(repo_id): Path, Query(query): Query, ) -> Result, ListPrsError>>, ApiError> { let repo = deployment .repo() .get_by_id(&deployment.db().pool, repo_id) .await?; let remote = match query.remote { Some(name) => GitRemote { url: deployment.git().get_remote_url(&repo.path, &name)?, name, }, None => deployment.git().get_default_remote(&repo.path)?, }; let git_host = match GitHostService::from_url(&remote.url) { Ok(host) => host, Err(GitHostError::UnsupportedProvider) => { return Ok(ResponseJson(ApiResponse::error_with_data( ListPrsError::UnsupportedProvider, ))); } Err(e) => { tracing::error!("Failed to create git host service: {}", e); return Ok(ResponseJson(ApiResponse::error(&e.to_string()))); } }; match git_host.list_open_prs(&repo.path, &remote.url).await { Ok(prs) => Ok(ResponseJson(ApiResponse::success(prs))), Err(GitHostError::CliNotInstalled { provider }) => Ok(ResponseJson( ApiResponse::error_with_data(ListPrsError::CliNotInstalled { provider }), )), Err(GitHostError::AuthFailed(message)) => Ok(ResponseJson(ApiResponse::error_with_data( ListPrsError::AuthFailed { message }, ))), Err(GitHostError::UnsupportedProvider) => Ok(ResponseJson(ApiResponse::error_with_data( ListPrsError::UnsupportedProvider, ))), Err(e) => { tracing::error!("Failed to list open PRs for repo {}: {}", repo_id, e); Ok(ResponseJson(ApiResponse::error(&e.to_string()))) } } } #[derive(Debug, Serialize, TS)] pub struct DeleteRepoConflict { pub message: String, pub workspaces: Vec, } pub async fn delete_repo( State(deployment): State, Path(repo_id): Path, ) -> Result< ( StatusCode, ResponseJson>, ), ApiError, > { let active = Repo::active_workspace_names(&deployment.db().pool, repo_id).await?; if !active.is_empty() { return Ok(( StatusCode::CONFLICT, ResponseJson(ApiResponse::error_with_data(DeleteRepoConflict { message: format!("Repository is used by {} active workspace(s)", active.len()), workspaces: active, })), )); } Repo::delete(&deployment.db().pool, repo_id).await?; Ok((StatusCode::OK, ResponseJson(ApiResponse::success(())))) } pub fn router() -> Router { Router::new() .route("/repos", get(get_repos).post(register_repo)) .route("/repos/recent", get(get_recent_repos)) .route("/repos/init", post(init_repo)) .route("/repos/batch", post(get_repos_batch)) .route( "/repos/{repo_id}", get(get_repo).put(update_repo).delete(delete_repo), ) .route("/repos/{repo_id}/branches", get(get_repo_branches)) .route("/repos/{repo_id}/remotes", get(get_repo_remotes)) .route("/repos/{repo_id}/prs", get(list_open_prs)) .route("/repos/{repo_id}/search", get(search_repo)) .route("/repos/{repo_id}/open-editor", post(open_repo_in_editor)) } ================================================ FILE: crates/server/src/routes/scratch.rs ================================================ use axum::{ Json, Router, extract::{Path, State, ws::Message}, response::{IntoResponse, Json as ResponseJson}, routing::get, }; use db::models::scratch::{CreateScratch, Scratch, ScratchType, UpdateScratch}; use deployment::Deployment; use futures_util::{StreamExt, TryStreamExt}; use serde::Deserialize; use utils::response::ApiResponse; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, }; /// Path parameters for scratch routes with composite key #[derive(Deserialize)] pub struct ScratchPath { scratch_type: ScratchType, id: Uuid, } pub async fn list_scratch( State(deployment): State, ) -> Result>>, ApiError> { let scratch_items = Scratch::find_all(&deployment.db().pool).await?; Ok(ResponseJson(ApiResponse::success(scratch_items))) } pub async fn get_scratch( State(deployment): State, Path(ScratchPath { scratch_type, id }): Path, ) -> Result>, ApiError> { let scratch = Scratch::find_by_id(&deployment.db().pool, id, &scratch_type) .await? .ok_or_else(|| ApiError::BadRequest("Scratch not found".to_string()))?; Ok(ResponseJson(ApiResponse::success(scratch))) } pub async fn create_scratch( State(deployment): State, Path(ScratchPath { scratch_type, id }): Path, Json(payload): Json, ) -> Result>, ApiError> { // Reject edits to draft_follow_up if a message is queued for this workspace if matches!(scratch_type, ScratchType::DraftFollowUp) && deployment.queued_message_service().has_queued(id) { return Err(ApiError::BadRequest( "Cannot edit scratch while a message is queued".to_string(), )); } // Validate that payload type matches URL type payload .payload .validate_type(scratch_type) .map_err(|e| ApiError::BadRequest(e.to_string()))?; let scratch = Scratch::create(&deployment.db().pool, id, &payload).await?; Ok(ResponseJson(ApiResponse::success(scratch))) } pub async fn update_scratch( State(deployment): State, Path(ScratchPath { scratch_type, id }): Path, Json(payload): Json, ) -> Result>, ApiError> { // Reject edits to draft_follow_up if a message is queued for this workspace if matches!(scratch_type, ScratchType::DraftFollowUp) && deployment.queued_message_service().has_queued(id) { return Err(ApiError::BadRequest( "Cannot edit scratch while a message is queued".to_string(), )); } // Validate that payload type matches URL type payload .payload .validate_type(scratch_type) .map_err(|e| ApiError::BadRequest(e.to_string()))?; // Upsert: creates if not exists, updates if exists let scratch = Scratch::update(&deployment.db().pool, id, &scratch_type, &payload).await?; Ok(ResponseJson(ApiResponse::success(scratch))) } pub async fn delete_scratch( State(deployment): State, Path(ScratchPath { scratch_type, id }): Path, ) -> Result>, ApiError> { let rows = Scratch::delete(&deployment.db().pool, id, &scratch_type).await?; if rows == 0 { return Err(ApiError::BadRequest("Scratch not found".to_string())); } Ok(ResponseJson(ApiResponse::success(()))) } pub async fn stream_scratch_ws( ws: SignedWsUpgrade, State(deployment): State, Path(ScratchPath { scratch_type, id }): Path, ) -> impl IntoResponse { ws.on_upgrade(move |socket| async move { if let Err(e) = handle_scratch_ws(socket, deployment, id, scratch_type).await { tracing::warn!("scratch WS closed: {}", e); } }) } async fn handle_scratch_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, id: Uuid, scratch_type: ScratchType, ) -> anyhow::Result<()> { let mut stream = deployment .events() .stream_scratch_raw(id, &scratch_type) .await? .map_ok(|msg| msg.to_ws_message_unchecked()); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("scratch stream error: {}", e); break; } None => break, } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } pub fn router(_deployment: &DeploymentImpl) -> Router { Router::new() .route("/scratch", get(list_scratch)) .route( "/scratch/{scratch_type}/{id}", get(get_scratch) .post(create_scratch) .put(update_scratch) .delete(delete_scratch), ) .route( "/scratch/{scratch_type}/{id}/stream/ws", get(stream_scratch_ws), ) } ================================================ FILE: crates/server/src/routes/search.rs ================================================ use axum::{ Router, extract::{Query, State}, response::Json as ResponseJson, routing::get, }; use db::models::repo::{Repo, SearchResult}; use deployment::Deployment; use serde::Deserialize; use services::services::file_search::{SearchMode, SearchQuery}; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct MultiRepoSearchQuery { pub q: String, #[serde(default)] pub mode: SearchMode, pub repo_ids: String, } pub async fn search_files( State(deployment): State, Query(query): Query, ) -> Result>>, ApiError> { let repo_ids: Vec = query .repo_ids .split(',') .filter(|s| !s.trim().is_empty()) .map(|s| s.trim().parse::()) .collect::, _>>() .map_err(|_| ApiError::BadRequest("Invalid repo_id format".to_string()))?; if repo_ids.is_empty() { return Err(ApiError::BadRequest( "repo_ids parameter is required".to_string(), )); } if query.q.trim().is_empty() { return Ok(ResponseJson(ApiResponse::error( "Query parameter 'q' is required and cannot be empty", ))); } let repos = Repo::find_by_ids(&deployment.db().pool, &repo_ids).await?; let search_query = SearchQuery { q: query.q, mode: query.mode, }; let results = deployment .repo() .search_files( deployment.file_search_cache().as_ref(), &repos, &search_query, ) .await .map_err(|e| { tracing::error!("Failed to search files: {}", e); ApiError::BadRequest(format!("Search failed: {}", e)) })?; Ok(ResponseJson(ApiResponse::success(results))) } pub fn router(deployment: &DeploymentImpl) -> Router { Router::new() .route("/search", get(search_files)) .with_state(deployment.clone()) } ================================================ FILE: crates/server/src/routes/sessions/mod.rs ================================================ pub mod queue; pub mod review; use axum::{ Extension, Json, Router, extract::{Query, State}, middleware::from_fn_with_state, response::Json as ResponseJson, routing::{get, post}, }; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessRunReason}, requests::UpdateSession, scratch::{Scratch, ScratchType}, session::{CreateSession, Session, SessionError}, workspace::{Workspace, WorkspaceError}, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use executors::{ actions::{ ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, }, profile::ExecutorConfig, }; use serde::Deserialize; use services::services::container::ContainerService; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, middleware::load_session_middleware, routes::workspaces::execution::RunScriptError, }; #[derive(Debug, Deserialize)] pub struct SessionQuery { pub workspace_id: Uuid, } #[derive(Debug, Deserialize, TS)] pub struct CreateSessionRequest { pub workspace_id: Uuid, pub executor: Option, pub name: Option, } pub async fn get_sessions( State(deployment): State, Query(query): Query, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let sessions = Session::find_by_workspace_id(pool, query.workspace_id).await?; Ok(ResponseJson(ApiResponse::success(sessions))) } pub async fn get_session( Extension(session): Extension, ) -> Result>, ApiError> { Ok(ResponseJson(ApiResponse::success(session))) } pub async fn create_session( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; // Verify workspace exists let _workspace = Workspace::find_by_id(pool, payload.workspace_id) .await? .ok_or(ApiError::Workspace(WorkspaceError::ValidationError( "Workspace not found".to_string(), )))?; let session = Session::create( pool, &CreateSession { executor: payload.executor, name: payload.name, }, Uuid::new_v4(), payload.workspace_id, ) .await?; Ok(ResponseJson(ApiResponse::success(session))) } pub async fn update_session( Extension(session): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; Session::update(pool, session.id, request.name.as_deref()).await?; let updated = Session::find_by_id(pool, session.id) .await? .ok_or(ApiError::Session(SessionError::NotFound))?; Ok(ResponseJson(ApiResponse::success(updated))) } #[derive(Debug, Deserialize, TS)] pub struct CreateFollowUpAttempt { pub prompt: String, pub executor_config: ExecutorConfig, pub retry_process_id: Option, pub force_when_dirty: Option, pub perform_git_reset: Option, } #[derive(Debug, Deserialize, TS)] pub struct ResetProcessRequest { pub process_id: Uuid, pub force_when_dirty: Option, pub perform_git_reset: Option, } pub async fn follow_up( Extension(session): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; // Load workspace from session let workspace = Workspace::find_by_id(pool, session.workspace_id) .await? .ok_or(ApiError::Workspace(WorkspaceError::ValidationError( "Workspace not found".to_string(), )))?; tracing::info!("{:?}", workspace); deployment .container() .ensure_container_exists(&workspace) .await?; let executor_profile_id = payload.executor_config.profile_id(); // Validate executor matches session if session has prior executions let expected_executor: Option = ExecutionProcess::latest_executor_profile_for_session(pool, session.id) .await? .map(|profile| profile.executor.to_string()) .or_else(|| session.executor.clone()); if let Some(expected) = expected_executor { let actual = executor_profile_id.executor.to_string(); if expected != actual { return Err(ApiError::Session(SessionError::ExecutorMismatch { expected, actual, })); } } if session.executor.is_none() { Session::update_executor(pool, session.id, &executor_profile_id.executor.to_string()) .await?; } if let Some(proc_id) = payload.retry_process_id { let force_when_dirty = payload.force_when_dirty.unwrap_or(false); let perform_git_reset = payload.perform_git_reset.unwrap_or(true); deployment .container() .reset_session_to_process(session.id, proc_id, perform_git_reset, force_when_dirty) .await?; } let latest_session_info = CodingAgentTurn::find_latest_session_info(pool, session.id).await?; let prompt = payload.prompt; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let cleanup_action = deployment.container().cleanup_actions_for_repos(&repos); let working_dir = session .agent_working_dir .as_ref() .filter(|dir| !dir.is_empty()) .cloned(); let action_type = if let Some(info) = latest_session_info { let is_reset = payload.retry_process_id.is_some(); ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { prompt: prompt.clone(), session_id: info.session_id, reset_to_message_id: if is_reset { info.message_id } else { None }, executor_config: payload.executor_config.clone(), working_dir: working_dir.clone(), }) } else { ExecutorActionType::CodingAgentInitialRequest( executors::actions::coding_agent_initial::CodingAgentInitialRequest { prompt, executor_config: payload.executor_config.clone(), working_dir, }, ) }; let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new)); let execution_process = deployment .container() .start_execution( &workspace, &session, &action, &ExecutionProcessRunReason::CodingAgent, ) .await?; // Clear the draft follow-up scratch on successful spawn // This ensures the scratch is wiped even if the user navigates away quickly if let Err(e) = Scratch::delete(pool, session.id, &ScratchType::DraftFollowUp).await { // Log but don't fail the request - scratch deletion is best-effort tracing::debug!( "Failed to delete draft follow-up scratch for session {}: {}", session.id, e ); } Ok(ResponseJson(ApiResponse::success(execution_process))) } pub async fn reset_process( Extension(session): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let force_when_dirty = payload.force_when_dirty.unwrap_or(false); let perform_git_reset = payload.perform_git_reset.unwrap_or(true); deployment .container() .reset_session_to_process( session.id, payload.process_id, perform_git_reset, force_when_dirty, ) .await?; Ok(ResponseJson(ApiResponse::success(()))) } pub async fn run_setup_script( Extension(session): Extension, State(deployment): State, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace = Workspace::find_by_id(pool, session.workspace_id) .await? .ok_or(ApiError::Workspace(WorkspaceError::ValidationError( "Workspace not found".to_string(), )))?; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id) .await? { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::ProcessAlreadyRunning, ))); } deployment .container() .ensure_container_exists(&workspace) .await?; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let executor_action = match deployment.container().setup_actions_for_repos(&repos) { Some(action) => action, None => { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::NoScriptConfigured, ))); } }; let execution_process = deployment .container() .start_execution( &workspace, &session, &executor_action, &ExecutionProcessRunReason::SetupScript, ) .await?; deployment .track_if_analytics_allowed( "setup_script_executed", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_process))) } pub fn router(deployment: &DeploymentImpl) -> Router { let session_id_router = Router::new() .route("/", get(get_session).put(update_session)) .route("/follow-up", post(follow_up)) .route("/reset", post(reset_process)) .route("/setup", post(run_setup_script)) .route("/review", post(review::start_review)) .layer(from_fn_with_state( deployment.clone(), load_session_middleware, )); let sessions_router = Router::new() .route("/", get(get_sessions).post(create_session)) .nest("/{session_id}", session_id_router) .nest("/{session_id}/queue", queue::router(deployment)); Router::new().nest("/sessions", sessions_router) } ================================================ FILE: crates/server/src/routes/sessions/queue.rs ================================================ use axum::{ Extension, Json, Router, extract::State, middleware::from_fn_with_state, response::Json as ResponseJson, routing::get, }; use db::models::{scratch::DraftFollowUpData, session::Session}; use deployment::Deployment; use executors::profile::ExecutorConfig; use serde::Deserialize; use services::services::queued_message::QueueStatus; use ts_rs::TS; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError, middleware::load_session_middleware}; /// Request body for queueing a follow-up message #[derive(Debug, Deserialize, TS)] pub struct QueueMessageRequest { pub message: String, pub executor_config: ExecutorConfig, } /// Queue a follow-up message to be executed when the current execution finishes pub async fn queue_message( Extension(session): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let data = DraftFollowUpData { message: payload.message, executor_config: payload.executor_config, }; let queued = deployment .queued_message_service() .queue_message(session.id, data); deployment .track_if_analytics_allowed( "follow_up_queued", serde_json::json!({ "session_id": session.id.to_string(), "workspace_id": session.workspace_id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(QueueStatus::Queued { message: queued, }))) } /// Cancel a queued follow-up message pub async fn cancel_queued_message( Extension(session): Extension, State(deployment): State, ) -> Result>, ApiError> { deployment .queued_message_service() .cancel_queued(session.id); deployment .track_if_analytics_allowed( "follow_up_queue_cancelled", serde_json::json!({ "session_id": session.id.to_string(), "workspace_id": session.workspace_id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(QueueStatus::Empty))) } /// Get the current queue status for a session's workspace pub async fn get_queue_status( Extension(session): Extension, State(deployment): State, ) -> Result>, ApiError> { let status = deployment.queued_message_service().get_status(session.id); Ok(ResponseJson(ApiResponse::success(status))) } pub fn router(deployment: &DeploymentImpl) -> Router { Router::new() .route( "/", get(get_queue_status) .post(queue_message) .delete(cancel_queued_message), ) .layer(from_fn_with_state( deployment.clone(), load_session_middleware, )) } ================================================ FILE: crates/server/src/routes/sessions/review.rs ================================================ use std::path::PathBuf; use axum::{Extension, Json, extract::State, response::Json as ResponseJson}; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessRunReason}, session::Session, workspace::{Workspace, WorkspaceError}, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use executors::{ actions::{ ExecutorAction, ExecutorActionType, review::{RepoReviewContext as ExecutorRepoReviewContext, ReviewRequest as ReviewAction}, }, executors::build_review_prompt, profile::ExecutorConfig, }; use serde::{Deserialize, Serialize}; use services::services::container::ContainerService; use ts_rs::TS; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] pub struct StartReviewRequest { pub executor_config: ExecutorConfig, pub additional_prompt: Option, #[serde(default)] pub use_all_workspace_commits: bool, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum ReviewError { ProcessAlreadyRunning, } #[axum::debug_handler] pub async fn start_review( Extension(session): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace = Workspace::find_by_id(pool, session.workspace_id) .await? .ok_or(ApiError::Workspace(WorkspaceError::ValidationError( "Workspace not found".to_string(), )))?; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id) .await? { return Ok(ResponseJson(ApiResponse::error_with_data( ReviewError::ProcessAlreadyRunning, ))); } let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let agent_session_id = CodingAgentTurn::find_latest_session_info(pool, session.id) .await? .map(|info| info.session_id); let context: Option> = if payload.use_all_workspace_commits { let repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id).await?; let workspace_path = PathBuf::from(container_ref.as_str()); let mut contexts = Vec::new(); for repo in repos { let worktree_path = workspace_path.join(&repo.repo.name); if let Ok(base_commit) = deployment.git().get_fork_point( &worktree_path, &repo.target_branch, &workspace.branch, ) { contexts.push(ExecutorRepoReviewContext { repo_id: repo.repo.id, repo_name: repo.repo.display_name, base_commit, }); } } if contexts.is_empty() { None } else { Some(contexts) } } else { None }; let prompt = build_review_prompt(context.as_deref(), payload.additional_prompt.as_deref()); let resumed_session = agent_session_id.is_some(); let action = ExecutorAction::new( ExecutorActionType::ReviewRequest(ReviewAction { executor_config: payload.executor_config.clone(), context, prompt, session_id: agent_session_id, working_dir: session.agent_working_dir.clone(), }), None, ); let execution_process = deployment .container() .start_execution( &workspace, &session, &action, &ExecutionProcessRunReason::CodingAgent, ) .await?; deployment .track_if_analytics_allowed( "review_started", serde_json::json!({ "workspace_id": workspace.id.to_string(), "session_id": session.id.to_string(), "executor": payload.executor_config.executor.to_string(), "variant": payload.executor_config.variant, "resumed_session": resumed_session, }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_process))) } ================================================ FILE: crates/server/src/routes/tags.rs ================================================ use axum::{ Extension, Json, Router, extract::{Query, State}, middleware::from_fn_with_state, response::Json as ResponseJson, routing::{get, put}, }; use db::models::tag::{CreateTag, Tag, UpdateTag}; use deployment::Deployment; use serde::Deserialize; use ts_rs::TS; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError, middleware::load_tag_middleware}; #[derive(Deserialize, TS)] pub struct TagSearchParams { #[serde(default)] pub search: Option, } pub async fn get_tags( State(deployment): State, Query(params): Query, ) -> Result>>, ApiError> { let mut tags = Tag::find_all(&deployment.db().pool).await?; // Filter by search query if provided if let Some(search_query) = params.search { let search_lower = search_query.to_lowercase(); tags.retain(|tag| tag.tag_name.to_lowercase().contains(&search_lower)); } Ok(ResponseJson(ApiResponse::success(tags))) } pub async fn create_tag( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let tag = Tag::create(&deployment.db().pool, &payload).await?; deployment .track_if_analytics_allowed( "tag_created", serde_json::json!({ "tag_id": tag.id.to_string(), "tag_name": tag.tag_name, }), ) .await; Ok(ResponseJson(ApiResponse::success(tag))) } pub async fn update_tag( Extension(tag): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let updated_tag = Tag::update(&deployment.db().pool, tag.id, &payload).await?; deployment .track_if_analytics_allowed( "tag_updated", serde_json::json!({ "tag_id": tag.id.to_string(), "tag_name": updated_tag.tag_name, }), ) .await; Ok(ResponseJson(ApiResponse::success(updated_tag))) } pub async fn delete_tag( Extension(tag): Extension, State(deployment): State, ) -> Result>, ApiError> { let rows_affected = Tag::delete(&deployment.db().pool, tag.id).await?; if rows_affected == 0 { Err(ApiError::Database(sqlx::Error::RowNotFound)) } else { Ok(ResponseJson(ApiResponse::success(()))) } } pub fn router(deployment: &DeploymentImpl) -> Router { let tag_router = Router::new() .route("/", put(update_tag).delete(delete_tag)) .layer(from_fn_with_state(deployment.clone(), load_tag_middleware)); let inner = Router::new() .route("/", get(get_tags).post(create_tag)) .nest("/{tag_id}", tag_router); Router::new().nest("/tags", inner) } ================================================ FILE: crates/server/src/routes/terminal.rs ================================================ use std::path::PathBuf; use axum::{ Router, extract::{Query, State, ws::Message}, response::IntoResponse, routing::get, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use db::models::{workspace::Workspace, workspace_repo::WorkspaceRepo}; use deployment::Deployment; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, }; #[derive(Debug, Deserialize)] pub struct TerminalQuery { pub workspace_id: Uuid, #[serde(default = "default_cols")] pub cols: u16, #[serde(default = "default_rows")] pub rows: u16, } fn default_cols() -> u16 { 80 } fn default_rows() -> u16 { 24 } #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum TerminalCommand { Input { data: String }, Resize { cols: u16, rows: u16 }, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum TerminalMessage { Output { data: String }, Error { message: String }, } pub async fn terminal_ws( ws: SignedWsUpgrade, State(deployment): State, Query(query): Query, ) -> Result { let attempt = Workspace::find_by_id(&deployment.db().pool, query.workspace_id) .await? .ok_or_else(|| ApiError::BadRequest("Attempt not found".to_string()))?; let container_ref = attempt .container_ref .ok_or_else(|| ApiError::BadRequest("Attempt has no workspace directory".to_string()))?; let base_dir = PathBuf::from(&container_ref); if !base_dir.exists() { return Err(ApiError::BadRequest( "Workspace directory does not exist".to_string(), )); } let mut working_dir = base_dir.clone(); match WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, query.workspace_id).await { Ok(repos) if repos.len() == 1 => { let repo_dir = base_dir.join(&repos[0].name); if repo_dir.exists() { working_dir = repo_dir; } } Ok(_) => {} Err(e) => { tracing::warn!( "Failed to resolve repos for workspace {}: {}", attempt.id, e ); } } Ok(ws.on_upgrade(move |socket| { handle_terminal_ws(socket, deployment, working_dir, query.cols, query.rows) })) } async fn handle_terminal_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, working_dir: PathBuf, cols: u16, rows: u16, ) { let (session_id, mut output_rx) = match deployment .pty() .create_session(working_dir, cols, rows) .await { Ok(result) => result, Err(e) => { tracing::error!("Failed to create PTY session: {}", e); let _ = send_error(&mut socket, &e.to_string()).await; return; } }; let pty_service = deployment.pty().clone(); let session_id_for_input = session_id; loop { tokio::select! { maybe_output = output_rx.recv() => { let Some(data) = maybe_output else { break; }; let msg = TerminalMessage::Output { data: BASE64.encode(&data), }; let json = match serde_json::to_string(&msg) { Ok(j) => j, Err(_) => continue, }; if socket.send(Message::Text(json.into())).await.is_err() { break; } } inbound = socket.recv() => { match inbound { Ok(Some(Message::Text(text))) => { if let Ok(cmd) = serde_json::from_str::(text.as_str()) { match cmd { TerminalCommand::Input { data } => { if let Ok(bytes) = BASE64.decode(&data) { let _ = pty_service.write(session_id_for_input, &bytes).await; } } TerminalCommand::Resize { cols, rows } => { let _ = pty_service.resize(session_id_for_input, cols, rows).await; } } } } Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(error) => { tracing::warn!("terminal WS receive error: {}", error); break; } } } } } let _ = deployment.pty().close_session(session_id).await; } async fn send_error(socket: &mut SignedWebSocket, message: &str) -> anyhow::Result<()> { let msg = TerminalMessage::Error { message: message.to_string(), }; let json = serde_json::to_string(&msg).unwrap_or_default(); socket.send(Message::Text(json.into())).await?; socket.close().await?; Ok(()) } pub fn router() -> Router { Router::new().route("/terminal/ws", get(terminal_ws)) } ================================================ FILE: crates/server/src/routes/workspaces/attachments.rs ================================================ use std::path::Path; use axum::{ Extension, Router, body::Body, extract::{DefaultBodyLimit, Multipart, Query, Request, State}, http::{StatusCode, header}, middleware::{Next, from_fn_with_state}, response::{Json as ResponseJson, Response}, routing::{get, post}, }; use db::models::{file::File, session::Session, workspace::Workspace}; use deployment::Deployment; use mime_guess::MimeGuess; use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, file::{FileError, FileService}, remote_client::RemoteClient, }; use tokio::fs::File as TokioFile; use tokio_util::io::ReaderStream; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, middleware::load_workspace_middleware, routes::attachments::{ AttachmentMetadata, AttachmentResponse, content_type_and_disposition_for_attachment, process_file_upload, }, }; #[derive(Debug, Deserialize)] pub struct AttachmentMetadataQuery { /// Path relative to worktree root, e.g., ".vibe-attachments/screenshot.png" pub path: String, pub session_id: Uuid, } #[derive(Debug, Deserialize)] pub struct SessionScopedQuery { pub session_id: Uuid, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct AssociateWorkspaceAttachmentsRequest { pub attachment_ids: Vec, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct ImportIssueAttachmentsRequest { pub issue_id: Uuid, } #[derive(Debug, Serialize, TS)] pub struct ImportIssueAttachmentsResponse { pub attachment_ids: Vec, } #[derive(Debug, Clone)] pub(crate) struct ImportedIssueAttachment { pub attachment_id: Uuid, pub file: File, } pub async fn get_workspace_files( Extension(workspace): Extension, State(deployment): State, ) -> Result>>, ApiError> { let files = File::find_by_workspace_id(&deployment.db().pool, workspace.id).await?; let attachment_responses = files .into_iter() .map(AttachmentResponse::from_file) .collect(); Ok(ResponseJson(ApiResponse::success(attachment_responses))) } pub async fn upload_file( Extension(workspace): Extension, State(deployment): State, Query(query): Query, multipart: Multipart, ) -> Result>, ApiError> { let attachment_response = process_file_upload(&deployment, multipart, Some(workspace.id)).await?; let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?; deployment .file() .copy_files_by_ids_to_worktree(&base_path, &[attachment_response.id]) .await?; Ok(ResponseJson(ApiResponse::success(attachment_response))) } pub async fn associate_workspace_attachments( Extension(workspace): Extension, State(deployment): State, axum::Json(payload): axum::Json, ) -> Result>, ApiError> { let managed_workspace = deployment .workspace_manager() .load_managed_workspace(workspace) .await?; managed_workspace .associate_attachments(&payload.attachment_ids) .await?; Ok(ResponseJson(ApiResponse::success(()))) } pub async fn import_issue_attachments( Extension(workspace): Extension, State(deployment): State, axum::Json(payload): axum::Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let imported_attachments = import_issue_attachments_from_remote(&client, deployment.file(), payload.issue_id).await?; let attachment_ids = imported_attachments .iter() .map(|imported| imported.file.id) .collect::>(); let managed_workspace = deployment .workspace_manager() .load_managed_workspace(workspace) .await?; managed_workspace .associate_attachments(&attachment_ids) .await?; Ok(ResponseJson(ApiResponse::success( ImportIssueAttachmentsResponse { attachment_ids }, ))) } pub async fn get_attachment_metadata( Extension(workspace): Extension, State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let vibe_attachments_prefix = format!("{}/", utils::path::VIBE_ATTACHMENTS_DIR); if !query.path.starts_with(&vibe_attachments_prefix) { return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata { exists: false, file_name: None, path: Some(query.path), size_bytes: None, format: None, proxy_url: None, }))); } if query.path.contains("..") { return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata { exists: false, file_name: None, path: Some(query.path), size_bytes: None, format: None, proxy_url: None, }))); } let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?; let file_path = query .path .strip_prefix(&vibe_attachments_prefix) .unwrap_or(""); ensure_workspace_attachment_exists(&deployment, &base_path, file_path).await?; let full_path = base_path.join(&query.path); let metadata = match tokio::fs::metadata(&full_path).await { Ok(m) if m.is_file() => m, _ => { return Ok(ResponseJson(ApiResponse::success(AttachmentMetadata { exists: false, file_name: None, path: Some(query.path), size_bytes: None, format: None, proxy_url: None, }))); } }; let file_name = Path::new(&query.path) .file_name() .map(|s| s.to_string_lossy().to_string()); let format = Path::new(&query.path) .extension() .map(|ext| ext.to_string_lossy().to_lowercase()); let proxy_url = format!( "/api/workspaces/{}/attachments/file/{}?session_id={}", workspace.id, file_path, query.session_id ); Ok(ResponseJson(ApiResponse::success(AttachmentMetadata { exists: true, file_name, path: Some(query.path), size_bytes: Some(metadata.len() as i64), format, proxy_url: Some(proxy_url), }))) } pub async fn serve_file( axum::extract::Path((_id, path)): axum::extract::Path<(Uuid, String)>, Extension(workspace): Extension, State(deployment): State, Query(query): Query, ) -> Result { if path.contains("..") { return Err(ApiError::File(FileError::NotFound)); } let base_path = resolve_session_base_path(&deployment, &workspace, query.session_id).await?; ensure_workspace_attachment_exists(&deployment, &base_path, &path).await?; let vibe_attachments_dir = base_path.join(utils::path::VIBE_ATTACHMENTS_DIR); let full_path = vibe_attachments_dir.join(&path); let canonical_path = tokio::fs::canonicalize(&full_path) .await .map_err(|_| ApiError::File(FileError::NotFound))?; let canonical_vibe_attachments = tokio::fs::canonicalize(&vibe_attachments_dir) .await .map_err(|_| ApiError::File(FileError::NotFound))?; if !canonical_path.starts_with(&canonical_vibe_attachments) { return Err(ApiError::File(FileError::NotFound)); } let file = TokioFile::open(&canonical_path) .await .map_err(|_| ApiError::File(FileError::NotFound))?; let metadata = file .metadata() .await .map_err(|_| ApiError::File(FileError::NotFound))?; let stream = ReaderStream::new(file); let body = Body::from_stream(stream); let content_type = MimeGuess::from_path(&path) .first_raw() .unwrap_or("application/octet-stream"); let (content_type, content_disposition) = content_type_and_disposition_for_attachment(content_type); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_LENGTH, metadata.len()) .header(header::CACHE_CONTROL, "public, max-age=31536000") .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff"); if let Some(content_disposition) = content_disposition { response = response.header(header::CONTENT_DISPOSITION, content_disposition); } let response = response .body(body) .map_err(|e| ApiError::File(FileError::ResponseBuildError(e.to_string())))?; Ok(response) } async fn ensure_workspace_attachment_exists( deployment: &DeploymentImpl, base_path: &Path, file_path: &str, ) -> Result<(), ApiError> { let attachment_dir = base_path.join(utils::path::VIBE_ATTACHMENTS_DIR); let full_path = attachment_dir.join(file_path); if full_path.exists() { return Ok(()); } let Some(file) = File::find_by_file_path(&deployment.db().pool, file_path).await? else { return Err(ApiError::File(FileError::NotFound)); }; deployment .file() .copy_files_by_ids_to_worktree(base_path, &[file.id]) .await?; Ok(()) } async fn resolve_session_base_path( deployment: &DeploymentImpl, workspace: &Workspace, session_id: Uuid, ) -> Result { let session = Session::find_by_id(&deployment.db().pool, session_id) .await? .ok_or_else(|| ApiError::BadRequest("Session not found".to_string()))?; if session.workspace_id != workspace.id { return Err(ApiError::BadRequest( "Session does not belong to workspace".to_string(), )); } let container_ref = deployment .container() .ensure_container_exists(workspace) .await?; let workspace_path = std::path::PathBuf::from(container_ref); let base_path = match session.agent_working_dir.as_deref() { Some(dir) if !dir.is_empty() => workspace_path.join(dir), _ => workspace_path, }; Ok(base_path) } /// Middleware to load Workspace for routes with wildcard path params. async fn load_workspace_with_wildcard( State(deployment): State, axum::extract::Path((id, _path)): axum::extract::Path<(Uuid, String)>, mut request: Request, next: Next, ) -> Result { let attempt = match Workspace::find_by_id(&deployment.db().pool, id).await { Ok(Some(a)) => a, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; request.extensions_mut().insert(attempt); Ok(next.run(request).await) } pub(crate) async fn import_issue_attachments_from_remote( client: &RemoteClient, file_service: &FileService, issue_id: Uuid, ) -> Result, ApiError> { let response = client .list_issue_attachments(issue_id) .await .map_err(ApiError::from)?; let mut imported_attachments = Vec::new(); for entry in response.attachments { let Some(file_url) = entry.file_url.as_deref() else { tracing::warn!( "No file_url for attachment {}, skipping", entry.attachment.id ); continue; }; let bytes = match client.download_from_url(file_url).await { Ok(bytes) => bytes, Err(error) => { tracing::warn!( "Failed to download attachment {}: {}", entry.attachment.id, error ); continue; } }; let file = match file_service .store_file(&bytes, &entry.attachment.original_name) .await { Ok(file) => file, Err(error) => { tracing::warn!( "Failed to store imported file '{}': {}", entry.attachment.original_name, error ); continue; } }; imported_attachments.push(ImportedIssueAttachment { attachment_id: entry.attachment.id, file, }); } Ok(imported_attachments) } pub fn router(deployment: &DeploymentImpl) -> Router { let metadata_router = Router::new() .route("/", get(get_workspace_files)) .route("/associate", post(associate_workspace_attachments)) .route("/import-issue-attachments", post(import_issue_attachments)) .route("/metadata", get(get_attachment_metadata)) .route( "/upload", post(upload_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), ) .layer(from_fn_with_state( deployment.clone(), load_workspace_middleware, )); let file_router = Router::new() .route("/file/{*path}", get(serve_file)) .layer(from_fn_with_state( deployment.clone(), load_workspace_with_wildcard, )); metadata_router.merge(file_router) } ================================================ FILE: crates/server/src/routes/workspaces/codex_setup.rs ================================================ use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessRunReason}, session::{CreateSession, Session}, workspace::{Workspace, WorkspaceError}, }; use deployment::Deployment; use executors::{ actions::{ ExecutorAction, ExecutorActionType, script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, }, command::{CommandBuilder, apply_overrides}, executors::{ExecutorError, codex::Codex}, }; use services::services::container::ContainerService; use uuid::Uuid; use crate::error::ApiError; pub async fn run_codex_setup( deployment: &crate::DeploymentImpl, workspace: &Workspace, codex: &Codex, ) -> Result { let latest_process = ExecutionProcess::find_latest_by_workspace_and_run_reason( &deployment.db().pool, workspace.id, &ExecutionProcessRunReason::CodingAgent, ) .await?; let executor_action = if let Some(latest_process) = latest_process { let latest_action = latest_process .executor_action() .map_err(|e| ApiError::Workspace(WorkspaceError::ValidationError(e.to_string())))?; get_setup_helper_action(codex) .await? .append_action(latest_action.to_owned()) } else { get_setup_helper_action(codex).await? }; deployment .container() .ensure_container_exists(workspace) .await?; // Get or create a session for setup scripts let session = match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? { Some(s) => s, None => { // Create a new session for setup scripts Session::create( &deployment.db().pool, &CreateSession { executor: Some("codex".to_string()), name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let execution_process = deployment .container() .start_execution( workspace, &session, &executor_action, &ExecutionProcessRunReason::SetupScript, ) .await?; Ok(execution_process) } async fn get_setup_helper_action(codex: &Codex) -> Result { let mut login_command = CommandBuilder::new(Codex::base_command()); login_command = login_command.extend_params(["login"]); login_command = apply_overrides(login_command, &codex.cmd)?; let (program_path, args) = login_command .build_initial() .map_err(|err| ApiError::Executor(ExecutorError::from(err)))? .into_resolved() .await .map_err(ApiError::Executor)?; let login_script = format!("{} {}", program_path.to_string_lossy(), args.join(" ")); let login_request = ScriptRequest { script: login_script, language: ScriptRequestLanguage::Bash, context: ScriptContext::ToolInstallScript, working_dir: None, }; Ok(ExecutorAction::new( ExecutorActionType::ScriptRequest(login_request), None, )) } ================================================ FILE: crates/server/src/routes/workspaces/core.rs ================================================ use axum::{ Extension, Json, extract::{Query, State}, http::StatusCode, response::Json as ResponseJson, }; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, workspace::{Workspace, WorkspaceError}, }; use deployment::Deployment; use serde::Deserialize; use services::services::{container::ContainerService, diff_stream, remote_sync}; use sqlx::Error as SqlxError; use utils::response::ApiResponse; use workspace_manager::WorkspaceManager; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize)] pub struct DeleteWorkspaceQuery { #[serde(default)] pub delete_remote: bool, #[serde(default)] pub delete_branches: bool, } pub async fn get_workspaces( State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let workspaces = Workspace::fetch_all(pool).await?; Ok(ResponseJson(ApiResponse::success(workspaces))) } pub async fn get_workspace( Extension(workspace): Extension, ) -> Result>, ApiError> { Ok(ResponseJson(ApiResponse::success(workspace))) } pub async fn update_workspace( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let is_archiving = request.archived == Some(true) && !workspace.archived; Workspace::update( pool, workspace.id, request.archived, request.pinned, request.name.as_deref(), ) .await?; let updated = Workspace::find_by_id(pool, workspace.id) .await? .ok_or(WorkspaceError::WorkspaceNotFound)?; if (request.archived.is_some() || request.name.is_some()) && let Ok(client) = deployment.remote_client() { let ws = updated.clone(); let name = request.name.clone(); let archived = request.archived; let stats = diff_stream::compute_diff_stats(&deployment.db().pool, deployment.git(), &ws).await; tokio::spawn(async move { remote_sync::sync_workspace_to_remote( &client, ws.id, name.map(Some), archived, stats.as_ref(), ) .await; }); } if is_archiving && let Err(e) = deployment.container().archive_workspace(workspace.id).await { tracing::error!("Failed to archive workspace {}: {}", workspace.id, e); } Ok(ResponseJson(ApiResponse::success(updated))) } pub async fn get_first_user_message( Extension(workspace): Extension, State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let message = Workspace::get_first_user_message(pool, workspace.id).await?; Ok(ResponseJson(ApiResponse::success(message))) } pub async fn delete_workspace( Extension(workspace): Extension, State(deployment): State, Query(query): Query, ) -> Result<(StatusCode, ResponseJson>), ApiError> { let pool = &deployment.db().pool; let workspace_manager = deployment.workspace_manager(); let workspace_id = workspace.id; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace_id) .await? { return Err(ApiError::Conflict( "Cannot delete workspace while processes are running. Stop all processes first." .to_string(), )); } let dev_servers = ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace_id).await?; for dev_server in dev_servers { tracing::info!( "Stopping dev server {} before deleting workspace {}", dev_server.id, workspace_id ); if let Err(e) = deployment .container() .stop_execution(&dev_server, ExecutionProcessStatus::Killed) .await { tracing::error!( "Failed to stop dev server {} for workspace {}: {}", dev_server.id, workspace_id, e ); } } let managed_workspace = workspace_manager.load_managed_workspace(workspace).await?; let deletion_context = managed_workspace.prepare_deletion_context().await?; let rows_affected = managed_workspace.delete_record().await?; if rows_affected == 0 { return Err(ApiError::Database(SqlxError::RowNotFound)); } deployment .track_if_analytics_allowed( "workspace_deleted", serde_json::json!({ "workspace_id": workspace_id.to_string(), }), ) .await; if query.delete_remote { if let Ok(client) = deployment.remote_client() { match client.delete_workspace(workspace_id).await { Ok(()) => { tracing::info!("Deleted remote workspace for {}", workspace_id); } Err(e) => { tracing::warn!( "Failed to delete remote workspace for {}: {}", workspace_id, e ); } } } else { tracing::debug!( "Remote client not available, skipping remote deletion for {}", workspace_id ); } } WorkspaceManager::spawn_workspace_deletion_cleanup(deletion_context, query.delete_branches); Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) } #[axum::debug_handler] pub async fn mark_seen( Extension(workspace): Extension, State(deployment): State, ) -> Result>, ApiError> { let pool = &deployment.db().pool; CodingAgentTurn::mark_seen_by_workspace_id(pool, workspace.id).await?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/workspaces/create.rs ================================================ use std::collections::HashMap; use axum::{Json, extract::State, response::Json as ResponseJson}; use db::models::{ requests::{ CreateAndStartWorkspaceRequest, CreateAndStartWorkspaceResponse, CreateWorkspaceApiRequest, }, workspace::{CreateWorkspace, Workspace}, }; use deployment::Deployment; use services::services::container::ContainerService; use utils::response::ApiResponse; use uuid::Uuid; use crate::{ DeploymentImpl, error::ApiError, routes::workspaces::attachments::{ ImportedIssueAttachment, import_issue_attachments_from_remote, }, }; pub(crate) async fn create_workspace_record( deployment: &DeploymentImpl, name: Option, ) -> Result { let workspace_id = Uuid::new_v4(); let branch_label = name .as_deref() .filter(|branch_label| !branch_label.is_empty()) .unwrap_or("workspace"); let git_branch_name = deployment .container() .git_branch_from_workspace(&workspace_id, branch_label) .await; let workspace = Workspace::create( &deployment.db().pool, &CreateWorkspace { branch: git_branch_name, name: name.filter(|workspace_name| !workspace_name.is_empty()), }, workspace_id, ) .await?; Ok(workspace) } pub async fn create_workspace( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let workspace = create_workspace_record(&deployment, payload.name).await?; deployment .track_if_analytics_allowed( "workspace_created", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(workspace))) } fn normalize_prompt(prompt: &str) -> Option { let trimmed = prompt.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn escape_markdown_label(label: &str) -> String { let mut escaped = String::with_capacity(label.len()); for ch in label.chars() { if matches!(ch, '[' | ']' | '\\') { escaped.push('\\'); } escaped.push(ch); } escaped } fn build_workspace_attachment_markdown( file: &ImportedIssueAttachment, label: &str, uses_image_markdown: bool, ) -> String { let path = format!(".vibe-attachments/{}", file.file.file_path); let normalized_label = if label.trim().is_empty() { file.file.original_name.as_str() } else { label }; let escaped_label = escape_markdown_label(normalized_label); if uses_image_markdown { format!("![{}]({})", escaped_label, path) } else { format!("[{}]({})", escaped_label, path) } } struct ParsedAttachmentMarkdown<'a> { attachment_id: Uuid, label: &'a str, uses_image_markdown: bool, end: usize, } fn find_unescaped_char(haystack: &str, target: char) -> Option { let mut escaped = false; for (index, ch) in haystack.char_indices() { if escaped { escaped = false; continue; } if ch == '\\' { escaped = true; continue; } if ch == target { return Some(index); } } None } fn parse_attachment_markdown_at( prompt: &str, start: usize, ) -> Option> { let rest = prompt.get(start..)?; let (uses_image_markdown, label_start_offset) = if rest.starts_with("![") { (true, 2) } else if rest.starts_with('[') { (false, 1) } else { return None; }; let label_rest = rest.get(label_start_offset..)?; let label_end_offset = find_unescaped_char(label_rest, ']')?; let label = &label_rest[..label_end_offset]; let after_label = label_rest.get(label_end_offset + 1..)?; let attachment_prefix = "(attachment://"; if !after_label.starts_with(attachment_prefix) { return None; } let attachment_id_start = start + label_start_offset + label_end_offset + 1 + attachment_prefix.len(); let attachment_id_rest = prompt.get(attachment_id_start..)?; let attachment_id_end_offset = attachment_id_rest.find(')')?; let attachment_id = Uuid::parse_str(&attachment_id_rest[..attachment_id_end_offset]).ok()?; Some(ParsedAttachmentMarkdown { attachment_id, label, uses_image_markdown, end: attachment_id_start + attachment_id_end_offset + 1, }) } fn rewrite_imported_issue_attachments_markdown( prompt: &str, imported_attachments: &[ImportedIssueAttachment], ) -> String { if imported_attachments.is_empty() { return prompt.to_string(); } let imported_by_attachment_id = imported_attachments .iter() .map(|attachment| (attachment.attachment_id, attachment)) .collect::>(); let mut rewritten = String::with_capacity(prompt.len()); let mut index = 0; while index < prompt.len() { if let Some(parsed) = parse_attachment_markdown_at(prompt, index) && let Some(attachment) = imported_by_attachment_id.get(&parsed.attachment_id) { rewritten.push_str(&build_workspace_attachment_markdown( attachment, parsed.label, parsed.uses_image_markdown, )); index = parsed.end; continue; } let Some(ch) = prompt[index..].chars().next() else { break; }; rewritten.push(ch); index += ch.len_utf8(); } rewritten } pub async fn create_and_start_workspace( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let CreateAndStartWorkspaceRequest { name, repos, linked_issue, executor_config, prompt, attachment_ids, } = payload; let mut workspace_prompt = normalize_prompt(&prompt).ok_or_else(|| { ApiError::BadRequest( "A workspace prompt is required. Provide a non-empty `prompt`.".to_string(), ) })?; if repos.is_empty() { return Err(ApiError::BadRequest( "At least one repository is required".to_string(), )); } let mut managed_workspace = deployment .workspace_manager() .load_managed_workspace(create_workspace_record(&deployment, name).await?) .await?; for repo in &repos { managed_workspace .add_repository(repo, deployment.git()) .await .map_err(ApiError::from)?; } if let Some(ids) = &attachment_ids { managed_workspace.associate_attachments(ids).await?; } if let Some(linked_issue) = &linked_issue && let Ok(client) = deployment.remote_client() { match import_issue_attachments_from_remote( &client, deployment.file(), linked_issue.issue_id, ) .await { Ok(imported_attachments) if !imported_attachments.is_empty() => { let imported_ids = imported_attachments .iter() .map(|imported| imported.file.id) .collect::>(); if let Err(e) = managed_workspace.associate_attachments(&imported_ids).await { tracing::warn!("Failed to associate imported files with workspace: {}", e); } workspace_prompt = rewrite_imported_issue_attachments_markdown( &workspace_prompt, &imported_attachments, ); tracing::info!( "Imported {} files from issue {}", imported_ids.len(), linked_issue.issue_id ); } Ok(_) => {} Err(e) => { tracing::warn!( "Failed to import issue attachments for issue {}: {}", linked_issue.issue_id, e ); } } } let workspace = managed_workspace.workspace.clone(); tracing::info!("Created workspace {}", workspace.id); let execution_process = deployment .container() .start_workspace(&workspace, executor_config.clone(), workspace_prompt) .await?; deployment .track_if_analytics_allowed( "workspace_created_and_started", serde_json::json!({ "executor": &executor_config.executor, "variant": &executor_config.variant, "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success( CreateAndStartWorkspaceResponse { workspace, execution_process, }, ))) } #[cfg(test)] mod tests { use chrono::Utc; use db::models::file::File; use uuid::Uuid; use super::{ImportedIssueAttachment, rewrite_imported_issue_attachments_markdown}; fn imported_file( attachment_id: Uuid, original_name: &str, file_path: &str, mime_type: Option<&str>, ) -> ImportedIssueAttachment { ImportedIssueAttachment { attachment_id, file: File { id: Uuid::new_v4(), file_path: file_path.to_string(), original_name: original_name.to_string(), mime_type: mime_type.map(str::to_string), size_bytes: 123, hash: "hash".to_string(), created_at: Utc::now(), updated_at: Utc::now(), }, } } #[test] fn rewrites_imported_non_image_attachment_links() { let attachment_id = Uuid::new_v4(); let prompt = format!("[proposal.pdf](attachment://{})", attachment_id); let imported = vec![imported_file( attachment_id, "proposal.pdf", "abc_proposal.pdf", Some("application/pdf"), )]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!( rewritten, "[proposal.pdf](.vibe-attachments/abc_proposal.pdf)" ); } #[test] fn preserves_authored_image_markdown_for_imported_images() { let attachment_id = Uuid::new_v4(); let prompt = format!("![diagram.png](attachment://{})", attachment_id); let imported = vec![imported_file( attachment_id, "diagram.png", "xyz_diagram.png", Some("image/png"), )]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!( rewritten, "![diagram.png](.vibe-attachments/xyz_diagram.png)" ); } #[test] fn preserves_authored_link_markdown_for_imported_images() { let attachment_id = Uuid::new_v4(); let prompt = format!("[diagram.png](attachment://{})", attachment_id); let imported = vec![imported_file( attachment_id, "diagram.png", "xyz_diagram.png", Some("image/png"), )]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!( rewritten, "[diagram.png](.vibe-attachments/xyz_diagram.png)" ); } #[test] fn preserves_authored_image_markdown_for_imported_non_images() { let attachment_id = Uuid::new_v4(); let prompt = format!("![proposal.pdf](attachment://{})", attachment_id); let imported = vec![imported_file( attachment_id, "proposal.pdf", "abc_proposal.pdf", Some("application/pdf"), )]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!( rewritten, "![proposal.pdf](.vibe-attachments/abc_proposal.pdf)" ); } #[test] fn leaves_unknown_attachment_references_unchanged() { let prompt = format!("[proposal.pdf](attachment://{})", Uuid::new_v4()); let imported = vec![imported_file( Uuid::new_v4(), "proposal.pdf", "abc_proposal.pdf", Some("application/pdf"), )]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!(rewritten, prompt); } #[test] fn rewrites_multiple_attachments_and_leaves_other_links_alone() { let image_attachment_id = Uuid::new_v4(); let file_attachment_id = Uuid::new_v4(); let prompt = format!( "See [doc.pdf](attachment://{}) and ![shot.png](attachment://{}). https://example.com", file_attachment_id, image_attachment_id ); let imported = vec![ imported_file( file_attachment_id, "doc.pdf", "doc_file.pdf", Some("application/pdf"), ), imported_file( image_attachment_id, "shot.png", "shot_file.png", Some("image/png"), ), ]; let rewritten = rewrite_imported_issue_attachments_markdown(&prompt, &imported); assert_eq!( rewritten, "See [doc.pdf](.vibe-attachments/doc_file.pdf) and ![shot.png](.vibe-attachments/shot_file.png). https://example.com" ); } } ================================================ FILE: crates/server/src/routes/workspaces/cursor_setup.rs ================================================ use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessRunReason}, session::{CreateSession, Session}, workspace::{Workspace, WorkspaceError}, }; use deployment::Deployment; use executors::actions::ExecutorAction; #[cfg(unix)] use executors::{ actions::{ ExecutorActionType, script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, }, executors::cursor::CursorAgent, }; use services::services::container::ContainerService; use uuid::Uuid; use crate::error::ApiError; pub async fn run_cursor_setup( deployment: &crate::DeploymentImpl, workspace: &Workspace, ) -> Result { let latest_process = ExecutionProcess::find_latest_by_workspace_and_run_reason( &deployment.db().pool, workspace.id, &ExecutionProcessRunReason::CodingAgent, ) .await?; let executor_action = if let Some(latest_process) = latest_process { let latest_action = latest_process .executor_action() .map_err(|e| ApiError::Workspace(WorkspaceError::ValidationError(e.to_string())))?; get_setup_helper_action() .await? .append_action(latest_action.to_owned()) } else { get_setup_helper_action().await? }; deployment .container() .ensure_container_exists(workspace) .await?; // Get or create a session for setup scripts let session = match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? { Some(s) => s, None => { Session::create( &deployment.db().pool, &CreateSession { executor: Some("cursor".to_string()), name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let execution_process = deployment .container() .start_execution( workspace, &session, &executor_action, &ExecutionProcessRunReason::SetupScript, ) .await?; Ok(execution_process) } async fn get_setup_helper_action() -> Result { #[cfg(unix)] { use shlex::try_quote; use utils::shell::UnixShell; let base_command = CursorAgent::base_command(); // Install script with PATH setup let mut install_script = format!( r#"#!/bin/bash set -e if ! command -v {base_command} &> /dev/null; then echo "Installing Cursor CLI..." curl https://cursor.com/install -fsS | bash echo "Installation complete!" else echo "Cursor CLI already installed" fi"# ); let shell = UnixShell::current_shell(); if let Some(config_file) = shell.config_file() && let Ok(config_file_str) = try_quote(config_file.to_string_lossy().as_ref()) { install_script.push_str(&format!( r#" echo "Setting up PATH..." echo 'export PATH="$HOME/.local/bin:$PATH"' >> {config_file_str} "# )); } let install_request = ScriptRequest { script: install_script, language: ScriptRequestLanguage::Bash, context: ScriptContext::ToolInstallScript, working_dir: None, }; // Second action (chained): Login let login_script = format!( r#"#!/bin/bash set -e export PATH="$HOME/.local/bin:$PATH" {base_command} login "# ); let login_request = ScriptRequest { script: login_script, language: ScriptRequestLanguage::Bash, context: ScriptContext::ToolInstallScript, working_dir: None, }; // Chain them: install → login Ok(ExecutorAction::new( ExecutorActionType::ScriptRequest(install_request), Some(Box::new(ExecutorAction::new( ExecutorActionType::ScriptRequest(login_request), None, ))), )) } #[cfg(not(unix))] { use executors::executors::ExecutorError::SetupHelperNotSupported; Err(ApiError::Executor(SetupHelperNotSupported)) } } ================================================ FILE: crates/server/src/routes/workspaces/execution.rs ================================================ use axum::{Extension, Router, extract::State, response::Json as ResponseJson, routing::post}; use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, session::{CreateSession, Session}, workspace::Workspace, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use executors::actions::{ ExecutorAction, ExecutorActionType, script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, }; use serde::{Deserialize, Serialize}; use services::services::container::ContainerService; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum RunScriptError { NoScriptConfigured, ProcessAlreadyRunning, } pub fn router() -> Router { Router::new() .route("/dev-server/start", post(start_dev_server)) .route("/cleanup", post(run_cleanup_script)) .route("/archive", post(run_archive_script)) .route("/stop", post(stop_workspace_execution)) } #[axum::debug_handler] pub async fn start_dev_server( Extension(workspace): Extension, State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let existing_dev_servers = match ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace.id).await { Ok(servers) => servers, Err(e) => { tracing::error!( "Failed to find running dev servers for workspace {}: {}", workspace.id, e ); return Err(ApiError::Workspace( db::models::workspace::WorkspaceError::ValidationError(e.to_string()), )); } }; for dev_server in existing_dev_servers { tracing::info!( "Stopping existing dev server {} for workspace {}", dev_server.id, workspace.id ); if let Err(e) = deployment .container() .stop_execution(&dev_server, ExecutionProcessStatus::Killed) .await { tracing::error!("Failed to stop dev server {}: {}", dev_server.id, e); } } let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let repos_with_dev_script: Vec<_> = repos .iter() .filter(|r| r.dev_server_script.as_ref().is_some_and(|s| !s.is_empty())) .collect(); if repos_with_dev_script.is_empty() { return Ok(ResponseJson(ApiResponse::error( "No dev server script configured for any repository in this workspace", ))); } let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? { Some(s) => s, None => { Session::create( pool, &CreateSession { executor: Some("dev-server".to_string()), name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let mut execution_processes = Vec::new(); for repo in repos_with_dev_script { let executor_action = ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: repo.dev_server_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::DevServer, working_dir: Some(repo.name.clone()), }), None, ); let execution_process = deployment .container() .start_execution( &workspace, &session, &executor_action, &ExecutionProcessRunReason::DevServer, ) .await?; execution_processes.push(execution_process); } deployment .track_if_analytics_allowed( "dev_server_started", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_processes))) } pub async fn stop_workspace_execution( Extension(workspace): Extension, State(deployment): State, ) -> Result>, ApiError> { deployment.container().try_stop(&workspace, false).await; deployment .track_if_analytics_allowed( "task_attempt_stopped", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(()))) } #[axum::debug_handler] pub async fn run_cleanup_script( Extension(workspace): Extension, State(deployment): State, ) -> Result>, ApiError> { let pool = &deployment.db().pool; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id) .await? { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::ProcessAlreadyRunning, ))); } deployment .container() .ensure_container_exists(&workspace) .await?; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let executor_action = match deployment.container().cleanup_actions_for_repos(&repos) { Some(action) => action, None => { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::NoScriptConfigured, ))); } }; let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? { Some(s) => s, None => { Session::create( pool, &CreateSession { executor: None, name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let execution_process = deployment .container() .start_execution( &workspace, &session, &executor_action, &ExecutionProcessRunReason::CleanupScript, ) .await?; deployment .track_if_analytics_allowed( "cleanup_script_executed", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_process))) } pub async fn run_archive_script( Extension(workspace): Extension, State(deployment): State, ) -> Result>, ApiError> { let pool = &deployment.db().pool; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id) .await? { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::ProcessAlreadyRunning, ))); } deployment .container() .ensure_container_exists(&workspace) .await?; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let executor_action = match deployment.container().archive_actions_for_repos(&repos) { Some(action) => action, None => { return Ok(ResponseJson(ApiResponse::error_with_data( RunScriptError::NoScriptConfigured, ))); } }; let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? { Some(s) => s, None => { Session::create( pool, &CreateSession { executor: None, name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let execution_process = deployment .container() .start_execution( &workspace, &session, &executor_action, &ExecutionProcessRunReason::ArchiveScript, ) .await?; deployment .track_if_analytics_allowed( "archive_script_executed", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_process))) } ================================================ FILE: crates/server/src/routes/workspaces/gh_cli_setup.rs ================================================ use db::models::{ execution_process::{ExecutionProcess, ExecutionProcessRunReason}, session::{CreateSession, Session}, workspace::Workspace, }; use deployment::Deployment; use executors::actions::ExecutorAction; #[cfg(unix)] use executors::{ actions::{ ExecutorActionType, script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, }, executors::ExecutorError, }; use serde::{Deserialize, Serialize}; use services::services::container::ContainerService; use ts_rs::TS; use uuid::Uuid; use crate::error::ApiError; #[derive(Debug, Serialize, Deserialize, TS)] #[ts(rename_all = "SCREAMING_SNAKE_CASE")] pub enum GhCliSetupError { BrewMissing, SetupHelperNotSupported, Other { message: String }, } pub async fn run_gh_cli_setup( deployment: &crate::DeploymentImpl, workspace: &Workspace, ) -> Result { let executor_action = get_gh_cli_setup_helper_action().await?; deployment .container() .ensure_container_exists(workspace) .await?; // Get or create a session for setup scripts let session = match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? { Some(s) => s, None => { Session::create( &deployment.db().pool, &CreateSession { executor: Some("gh-cli".to_string()), name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; let execution_process = deployment .container() .start_execution( workspace, &session, &executor_action, &ExecutionProcessRunReason::SetupScript, ) .await?; Ok(execution_process) } async fn get_gh_cli_setup_helper_action() -> Result { #[cfg(unix)] { use utils::shell::resolve_executable_path; if resolve_executable_path("brew").await.is_none() { return Err(ApiError::Executor(ExecutorError::ExecutableNotFound { program: "brew".to_string(), })); } // Install script let install_script = r#"#!/bin/bash set -e if ! command -v gh &> /dev/null; then echo "Installing GitHub CLI..." brew install gh echo "Installation complete!" else echo "GitHub CLI already installed" fi"# .to_string(); let install_request = ScriptRequest { script: install_script, language: ScriptRequestLanguage::Bash, context: ScriptContext::ToolInstallScript, working_dir: None, }; // Auth script let auth_script = r#"#!/bin/bash set -e export GH_PROMPT_DISABLED=1 gh auth login --web --git-protocol https --skip-ssh-key "# .to_string(); let auth_request = ScriptRequest { script: auth_script, language: ScriptRequestLanguage::Bash, context: ScriptContext::ToolInstallScript, working_dir: None, }; // Chain them: install → auth Ok(ExecutorAction::new( ExecutorActionType::ScriptRequest(install_request), Some(Box::new(ExecutorAction::new( ExecutorActionType::ScriptRequest(auth_request), None, ))), )) } #[cfg(not(unix))] { use executors::executors::ExecutorError::SetupHelperNotSupported; Err(ApiError::Executor(SetupHelperNotSupported)) } } ================================================ FILE: crates/server/src/routes/workspaces/git.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, }; use axum::{ Extension, Json, Router, extract::State, response::{IntoResponse, Json as ResponseJson}, routing::{get, post}, }; use db::models::{ merge::{Merge, MergeStatus, PrMerge, PullRequestInfo}, repo::{Repo, RepoError}, workspace::Workspace, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use git::{ConflictOp, GitCliError, GitServiceError}; use git2::BranchType; use serde::{Deserialize, Serialize}; use services::services::{container::ContainerService, diff_stream, remote_sync}; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use super::streams::{DiffStreamQuery, stream_workspace_diff_ws}; use crate::{DeploymentImpl, error::ApiError, routes::relay_ws::SignedWsUpgrade}; #[derive(Debug, Deserialize, Serialize, TS)] pub struct RebaseWorkspaceRequest { pub repo_id: Uuid, pub old_base_branch: Option, pub new_base_branch: Option, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct AbortConflictsRequest { pub repo_id: Uuid, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct ContinueRebaseRequest { pub repo_id: Uuid, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum GitOperationError { MergeConflicts { message: String, op: ConflictOp, conflicted_files: Vec, target_branch: String, }, RebaseInProgress, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct MergeWorkspaceRequest { pub repo_id: Uuid, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct PushWorkspaceRequest { pub repo_id: Uuid, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum PushError { ForcePushRequired, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct BranchStatus { pub commits_behind: Option, pub commits_ahead: Option, pub has_uncommitted_changes: Option, pub head_oid: Option, pub uncommitted_count: Option, pub untracked_count: Option, pub target_branch_name: String, pub remote_commits_behind: Option, pub remote_commits_ahead: Option, pub merges: Vec, pub is_rebase_in_progress: bool, pub conflict_op: Option, pub conflicted_files: Vec, pub is_target_remote: bool, } #[derive(Debug, Clone, Serialize, TS)] pub struct RepoBranchStatus { pub repo_id: Uuid, pub repo_name: String, #[serde(flatten)] pub status: BranchStatus, } #[derive(Deserialize, Debug, TS)] pub struct ChangeTargetBranchRequest { pub repo_id: Uuid, pub new_target_branch: String, } #[derive(Serialize, Debug, TS)] pub struct ChangeTargetBranchResponse { pub repo_id: Uuid, pub new_target_branch: String, pub status: (usize, usize), } #[derive(Deserialize, Debug, TS)] pub struct RenameBranchRequest { pub new_branch_name: String, } #[derive(Serialize, Debug, TS)] pub struct RenameBranchResponse { pub branch: String, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum RenameBranchError { EmptyBranchName, InvalidBranchNameFormat, OpenPullRequest, BranchAlreadyExists { repo_name: String }, RebaseInProgress { repo_name: String }, RenameFailed { repo_name: String, message: String }, } pub fn router() -> Router { Router::new() .route("/status", get(get_workspace_branch_status)) .route("/diff/ws", get(stream_diff_ws)) .route("/merge", post(merge_workspace)) .route("/push", post(push_workspace_branch)) .route("/push/force", post(force_push_workspace_branch)) .route("/rebase", post(rebase_workspace)) .route("/rebase/continue", post(continue_workspace_rebase)) .route("/conflicts/abort", post(abort_workspace_conflicts)) .route("/target-branch", axum::routing::put(change_target_branch)) .route("/branch", axum::routing::put(rename_branch)) } async fn resolve_vibe_kanban_identifier( deployment: &DeploymentImpl, local_workspace_id: Uuid, ) -> String { if let Ok(client) = deployment.remote_client() && let Ok(remote_ws) = client.get_workspace_by_local_id(local_workspace_id).await && let Some(issue_id) = remote_ws.issue_id && let Ok(issue) = client.get_issue(issue_id).await { if !issue.simple_id.is_empty() { return issue.simple_id; } return issue_id.to_string(); } local_workspace_id.to_string() } #[axum::debug_handler] pub async fn stream_diff_ws( ws: SignedWsUpgrade, query: axum::extract::Query, workspace: Extension, deployment: State, ) -> impl IntoResponse { stream_workspace_diff_ws(ws, query, workspace, deployment).await } #[axum::debug_handler] pub async fn merge_workspace( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id).await?; let has_open_pr = merges .iter() .any(|m| matches!(m, Merge::Pr(pr) if matches!(pr.pr_info.status, MergeStatus::Open))); if has_open_pr { return Err(ApiError::BadRequest( "Cannot merge directly when a pull request is open for this repository.".to_string(), )); } let target_branch_type = deployment .git() .find_branch_type(&repo.path, &workspace_repo.target_branch)?; if target_branch_type == BranchType::Remote { return Err(ApiError::BadRequest( "Cannot merge directly into a remote branch. Please create a pull request instead." .to_string(), )); } let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(repo.name); let workspace_label = workspace.name.as_deref().unwrap_or(&workspace.branch); let vk_id = resolve_vibe_kanban_identifier(&deployment, workspace.id).await; let commit_message = format!("{} (vibe-kanban {})", workspace_label, vk_id); let merge_commit_id = deployment.git().merge_changes( &repo.path, &worktree_path, &workspace.branch, &workspace_repo.target_branch, &commit_message, )?; Merge::create_direct( pool, workspace.id, workspace_repo.repo_id, &workspace_repo.target_branch, &merge_commit_id, ) .await?; if let Ok(client) = deployment.remote_client() { let workspace_id = workspace.id; tokio::spawn(async move { remote_sync::sync_local_workspace_merge_to_remote(&client, workspace_id).await; }); } if !workspace.pinned && let Err(e) = deployment.container().archive_workspace(workspace.id).await { tracing::error!("Failed to archive workspace {}: {}", workspace.id, e); } deployment .track_if_analytics_allowed( "task_attempt_merged", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(()))) } pub async fn push_workspace_branch( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(&repo.name); match deployment .git() .push_to_remote(&worktree_path, &workspace.branch, false) { Ok(_) => { if let Ok(client) = deployment.remote_client() { let pool = deployment.db().pool.clone(); let git = deployment.git().clone(); let mut ws = workspace.clone(); ws.container_ref = Some(container_ref.clone()); tokio::spawn(async move { let stats = diff_stream::compute_diff_stats(&pool, &git, &ws).await; remote_sync::sync_workspace_to_remote( &client, ws.id, None, None, stats.as_ref(), ) .await; }); } Ok(ResponseJson(ApiResponse::success(()))) } Err(GitServiceError::GitCLI(GitCliError::PushRejected(_))) => Ok(ResponseJson( ApiResponse::error_with_data(PushError::ForcePushRequired), )), Err(e) => Err(ApiError::GitService(e)), } } pub async fn force_push_workspace_branch( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(&repo.name); deployment .git() .push_to_remote(&worktree_path, &workspace.branch, true)?; if let Ok(client) = deployment.remote_client() { let pool = deployment.db().pool.clone(); let git = deployment.git().clone(); let mut ws = workspace.clone(); ws.container_ref = Some(container_ref.clone()); tokio::spawn(async move { let stats = diff_stream::compute_diff_stats(&pool, &git, &ws).await; remote_sync::sync_workspace_to_remote(&client, ws.id, None, None, stats.as_ref()).await; }); } Ok(ResponseJson(ApiResponse::success(()))) } pub async fn get_workspace_branch_status( Extension(workspace): Extension, State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let repositories = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let workspace_repos = WorkspaceRepo::find_by_workspace_id(pool, workspace.id).await?; let target_branches: HashMap<_, _> = workspace_repos .iter() .map(|wr| (wr.repo_id, wr.target_branch.clone())) .collect(); let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_dir = PathBuf::from(&container_ref); let all_merges = Merge::find_by_workspace_id(pool, workspace.id).await?; let merges_by_repo: HashMap> = all_merges .into_iter() .fold(HashMap::new(), |mut acc, merge| { let repo_id = match &merge { Merge::Direct(dm) => dm.repo_id, Merge::Pr(pm) => pm.repo_id, }; acc.entry(repo_id).or_insert_with(Vec::new).push(merge); acc }); let mut results = Vec::with_capacity(repositories.len()); for repo in repositories { let Some(target_branch) = target_branches.get(&repo.id).cloned() else { continue; }; let repo_merges = merges_by_repo.get(&repo.id).cloned().unwrap_or_default(); let worktree_path = workspace_dir.join(&repo.name); let head_oid = deployment .git() .get_head_info(&worktree_path) .ok() .map(|h| h.oid); let (is_rebase_in_progress, conflicted_files, conflict_op) = { let in_rebase = deployment .git() .is_rebase_in_progress(&worktree_path) .unwrap_or(false); let conflicts = deployment .git() .get_conflicted_files(&worktree_path) .unwrap_or_default(); let op = if conflicts.is_empty() { None } else { deployment .git() .detect_conflict_op(&worktree_path) .unwrap_or(None) }; (in_rebase, conflicts, op) }; let (uncommitted_count, untracked_count) = match deployment.git().get_worktree_change_counts(&worktree_path) { Ok((a, b)) => (Some(a), Some(b)), Err(_) => (None, None), }; let has_uncommitted_changes = uncommitted_count.map(|c| c > 0); let target_branch_type = deployment .git() .find_branch_type(&repo.path, &target_branch)?; let (commits_ahead, commits_behind) = match target_branch_type { BranchType::Local => { let (a, b) = deployment.git().get_branch_status( &repo.path, &workspace.branch, &target_branch, )?; (Some(a), Some(b)) } BranchType::Remote => { let (ahead, behind) = deployment.git().get_remote_branch_status( &repo.path, &workspace.branch, Some(&target_branch), )?; (Some(ahead), Some(behind)) } }; let (remote_ahead, remote_behind) = if let Some(Merge::Pr(PrMerge { pr_info: PullRequestInfo { status: MergeStatus::Open, .. }, .. })) = repo_merges.first() { match deployment .git() .get_remote_branch_status(&repo.path, &workspace.branch, None) { Ok((ahead, behind)) => (Some(ahead), Some(behind)), Err(_) => (None, None), } } else { (None, None) }; results.push(RepoBranchStatus { repo_id: repo.id, repo_name: repo.name, status: BranchStatus { commits_ahead, commits_behind, has_uncommitted_changes, head_oid, uncommitted_count, untracked_count, remote_commits_ahead: remote_ahead, remote_commits_behind: remote_behind, merges: repo_merges, target_branch_name: target_branch, is_rebase_in_progress, conflict_op, conflicted_files, is_target_remote: target_branch_type == BranchType::Remote, }, }); } Ok(ResponseJson(ApiResponse::success(results))) } #[axum::debug_handler] pub async fn change_target_branch( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let repo_id = payload.repo_id; let new_target_branch = payload.new_target_branch; let pool = &deployment.db().pool; let repo = Repo::find_by_id(pool, repo_id) .await? .ok_or(RepoError::NotFound)?; if !deployment .git() .check_branch_exists(&repo.path, &new_target_branch)? { return Ok(ResponseJson(ApiResponse::error( format!( "Branch '{}' does not exist in repository '{}'", new_target_branch, repo.name ) .as_str(), ))); }; WorkspaceRepo::update_target_branch(pool, workspace.id, repo_id, &new_target_branch).await?; let status = deployment .git() .get_branch_status(&repo.path, &workspace.branch, &new_target_branch)?; deployment .track_if_analytics_allowed( "task_attempt_target_branch_changed", serde_json::json!({ "repo_id": repo_id.to_string(), "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success( ChangeTargetBranchResponse { repo_id, new_target_branch, status, }, ))) } #[axum::debug_handler] pub async fn rename_branch( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let new_branch_name = payload.new_branch_name.trim(); if new_branch_name.is_empty() { return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::EmptyBranchName, ))); } if !deployment.git().is_branch_name_valid(new_branch_name) { return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::InvalidBranchNameFormat, ))); } if new_branch_name == workspace.branch { return Ok(ResponseJson(ApiResponse::success(RenameBranchResponse { branch: workspace.branch.clone(), }))); } let pool = &deployment.db().pool; let merges = Merge::find_by_workspace_id(pool, workspace.id).await?; let has_open_pr = merges.into_iter().any(|merge| { matches!(merge, Merge::Pr(pr_merge) if matches!(pr_merge.pr_info.status, MergeStatus::Open)) }); if has_open_pr { return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::OpenPullRequest, ))); } let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_dir = PathBuf::from(&container_ref); for repo in &repos { let worktree_path = workspace_dir.join(&repo.name); if deployment .git() .check_branch_exists(&repo.path, new_branch_name)? { return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::BranchAlreadyExists { repo_name: repo.name.clone(), }, ))); } if deployment.git().is_rebase_in_progress(&worktree_path)? { return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::RebaseInProgress { repo_name: repo.name.clone(), }, ))); } } let old_branch = workspace.branch.clone(); let mut renamed_repos: Vec<&Repo> = Vec::new(); for repo in &repos { let worktree_path = workspace_dir.join(&repo.name); match deployment.git().rename_local_branch( &worktree_path, &workspace.branch, new_branch_name, ) { Ok(()) => { renamed_repos.push(repo); } Err(e) => { for renamed_repo in &renamed_repos { let rollback_path = workspace_dir.join(&renamed_repo.name); if let Err(rollback_err) = deployment.git().rename_local_branch( &rollback_path, new_branch_name, &old_branch, ) { tracing::error!( "Failed to rollback branch rename in '{}': {}", renamed_repo.name, rollback_err ); } } return Ok(ResponseJson(ApiResponse::error_with_data( RenameBranchError::RenameFailed { repo_name: repo.name.clone(), message: e.to_string(), }, ))); } } } db::models::workspace::Workspace::update_branch_name(pool, workspace.id, new_branch_name) .await?; let updated_children_count = WorkspaceRepo::update_target_branch_for_children_of_workspace( pool, workspace.id, &old_branch, new_branch_name, ) .await?; if updated_children_count > 0 { tracing::info!( "Updated {} child workspaces to target new branch '{}'", updated_children_count, new_branch_name ); } deployment .track_if_analytics_allowed( "task_attempt_branch_renamed", serde_json::json!({ "updated_children": updated_children_count, }), ) .await; Ok(ResponseJson(ApiResponse::success(RenameBranchResponse { branch: new_branch_name.to_string(), }))) } #[axum::debug_handler] pub async fn rebase_workspace( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, payload.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; let old_base_branch = payload .old_base_branch .unwrap_or_else(|| workspace_repo.target_branch.clone()); let new_base_branch = payload .new_base_branch .unwrap_or_else(|| workspace_repo.target_branch.clone()); match deployment .git() .check_branch_exists(&repo.path, &new_base_branch)? { true => { WorkspaceRepo::update_target_branch( pool, workspace.id, payload.repo_id, &new_base_branch, ) .await?; } false => { return Ok(ResponseJson(ApiResponse::error( format!( "Branch '{}' does not exist in the repository", new_base_branch ) .as_str(), ))); } } let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(&repo.name); let result = deployment.git().rebase_branch( &repo.path, &worktree_path, &new_base_branch, &old_base_branch, &workspace.branch.clone(), ); if let Err(e) = result { return match e { GitServiceError::MergeConflicts { message, conflicted_files, } => Ok(ResponseJson( ApiResponse::<(), GitOperationError>::error_with_data( GitOperationError::MergeConflicts { message, op: ConflictOp::Rebase, conflicted_files, target_branch: new_base_branch.clone(), }, ), )), GitServiceError::RebaseInProgress => Ok(ResponseJson(ApiResponse::< (), GitOperationError, >::error_with_data( GitOperationError::RebaseInProgress, ))), other => Err(ApiError::GitService(other)), }; } deployment .track_if_analytics_allowed( "task_attempt_rebased", serde_json::json!({ "workspace_id": workspace.id.to_string(), "repo_id": payload.repo_id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(()))) } #[axum::debug_handler] pub async fn abort_workspace_conflicts( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let repo = Repo::find_by_id(pool, payload.repo_id) .await? .ok_or(RepoError::NotFound)?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(&repo.name); deployment.git().abort_conflicts(&worktree_path)?; Ok(ResponseJson(ApiResponse::success(()))) } #[axum::debug_handler] pub async fn continue_workspace_rebase( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let repo = Repo::find_by_id(pool, payload.repo_id) .await? .ok_or(RepoError::NotFound)?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = Path::new(&container_ref); let worktree_path = workspace_path.join(&repo.name); deployment.git().continue_rebase(&worktree_path)?; Ok(ResponseJson(ApiResponse::success(()))) } ================================================ FILE: crates/server/src/routes/workspaces/integration.rs ================================================ use std::path::Path; use axum::{ Extension, Json, Router, extract::State, response::Json as ResponseJson, routing::post, }; use db::models::{workspace::Workspace, workspace_repo::WorkspaceRepo}; use deployment::Deployment; use executors::{ executors::{CodingAgent, ExecutorError}, profile::{ExecutorConfigs, ExecutorProfileId}, }; use serde::{Deserialize, Serialize}; use services::services::container::ContainerService; use ts_rs::TS; use utils::response::ApiResponse; use super::{codex_setup, cursor_setup, gh_cli_setup::GhCliSetupError}; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] pub struct RunAgentSetupRequest { pub executor_profile_id: ExecutorProfileId, } #[derive(Debug, Serialize, TS)] pub struct RunAgentSetupResponse {} #[derive(Deserialize, TS)] pub struct OpenEditorRequest { editor_type: Option, file_path: Option, } #[derive(Debug, Serialize, TS)] pub struct OpenEditorResponse { pub url: Option, } pub fn router() -> Router { Router::new() .route("/editor/open", post(open_workspace_in_editor)) .route("/agent/setup", post(run_agent_setup)) .route("/github/cli/setup", post(gh_cli_setup_handler)) } #[axum::debug_handler] pub async fn run_agent_setup( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let executor_profile_id = payload.executor_profile_id; let config = ExecutorConfigs::get_cached(); let coding_agent = config.get_coding_agent_or_default(&executor_profile_id); match coding_agent { CodingAgent::CursorAgent(_) => { cursor_setup::run_cursor_setup(&deployment, &workspace).await?; } CodingAgent::Codex(codex) => { codex_setup::run_codex_setup(&deployment, &workspace, &codex).await?; } _ => return Err(ApiError::Executor(ExecutorError::SetupHelperNotSupported)), } deployment .track_if_analytics_allowed( "agent_setup_script_executed", serde_json::json!({ "executor_profile_id": executor_profile_id.to_string(), "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(RunAgentSetupResponse {}))) } pub async fn open_workspace_in_editor( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; deployment.container().touch(&workspace).await?; let workspace_path = Path::new(&container_ref); let workspace_repos = WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await?; let workspace_path = if workspace_repos.len() == 1 && payload.file_path.is_none() { workspace_path.join(&workspace_repos[0].name) } else { workspace_path.to_path_buf() }; let path = if let Some(file_path) = payload.file_path.as_ref() { workspace_path.join(file_path) } else { workspace_path }; let editor_config = { let config = deployment.config().read().await; let editor_type_str = payload.editor_type.as_deref(); config.editor.with_override(editor_type_str) }; match editor_config.open_file(path.as_path()).await { Ok(url) => { tracing::info!( "Opened editor for workspace {} at path: {}{}", workspace.id, path.display(), if url.is_some() { " (remote mode)" } else { "" } ); deployment .track_if_analytics_allowed( "task_attempt_editor_opened", serde_json::json!({ "workspace_id": workspace.id.to_string(), "editor_type": payload.editor_type.as_ref(), "remote_mode": url.is_some(), }), ) .await; Ok(ResponseJson(ApiResponse::success(OpenEditorResponse { url, }))) } Err(e) => { tracing::error!( "Failed to open editor for attempt {}: {:?}", workspace.id, e ); Err(ApiError::EditorOpen(e)) } } } #[axum::debug_handler] pub async fn gh_cli_setup_handler( Extension(workspace): Extension, State(deployment): State, ) -> Result< ResponseJson>, ApiError, > { match super::gh_cli_setup::run_gh_cli_setup(&deployment, &workspace).await { Ok(execution_process) => { deployment .track_if_analytics_allowed( "gh_cli_setup_executed", serde_json::json!({ "workspace_id": workspace.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success(execution_process))) } Err(ApiError::Executor(executors::executors::ExecutorError::ExecutableNotFound { program, })) if program == "brew" => Ok(ResponseJson(ApiResponse::error_with_data( GhCliSetupError::BrewMissing, ))), Err(ApiError::Executor(ExecutorError::SetupHelperNotSupported)) => Ok(ResponseJson( ApiResponse::error_with_data(GhCliSetupError::SetupHelperNotSupported), )), Err(ApiError::Executor(err)) => Ok(ResponseJson(ApiResponse::error_with_data( GhCliSetupError::Other { message: err.to_string(), }, ))), Err(err) => Err(err), } } ================================================ FILE: crates/server/src/routes/workspaces/links.rs ================================================ use api_types::{CreateWorkspaceRequest, PullRequestStatus, UpsertPullRequestRequest}; use axum::{ Extension, Json, Router, extract::{Path as AxumPath, State}, middleware::from_fn_with_state, response::Json as ResponseJson, routing::{delete, post}, }; use db::models::{ merge::{Merge, MergeStatus}, workspace::Workspace, }; use deployment::Deployment; use serde::Deserialize; use services::services::{diff_stream, remote_client::RemoteClientError, remote_sync}; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError, middleware::load_workspace_middleware}; #[derive(Debug, Deserialize)] pub struct LinkWorkspaceRequest { pub project_id: Uuid, pub issue_id: Uuid, } pub async fn link_workspace( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let client = deployment.remote_client()?; let stats = diff_stream::compute_diff_stats(&deployment.db().pool, deployment.git(), &workspace).await; client .create_workspace(CreateWorkspaceRequest { project_id: payload.project_id, local_workspace_id: workspace.id, issue_id: payload.issue_id, name: workspace.name.clone(), archived: Some(workspace.archived), files_changed: stats.as_ref().map(|s| s.files_changed as i32), lines_added: stats.as_ref().map(|s| s.lines_added as i32), lines_removed: stats.as_ref().map(|s| s.lines_removed as i32), }) .await?; { let pool = deployment.db().pool.clone(); let ws_id = workspace.id; let client = client.clone(); tokio::spawn(async move { let merges = match Merge::find_by_workspace_id(&pool, ws_id).await { Ok(m) => m, Err(e) => { tracing::error!( "Failed to fetch merges for workspace {} during link: {}", ws_id, e ); return; } }; for merge in merges { if let Merge::Pr(pr_merge) = merge { let pr_status = match pr_merge.pr_info.status { MergeStatus::Open => PullRequestStatus::Open, MergeStatus::Merged => PullRequestStatus::Merged, MergeStatus::Closed => PullRequestStatus::Closed, MergeStatus::Unknown => continue, }; remote_sync::sync_pr_to_remote( &client, UpsertPullRequestRequest { url: pr_merge.pr_info.url, number: pr_merge.pr_info.number as i32, status: pr_status, merged_at: pr_merge.pr_info.merged_at, merge_commit_sha: pr_merge.pr_info.merge_commit_sha, target_branch_name: pr_merge.target_branch_name, local_workspace_id: ws_id, }, ) .await; } } }); } Ok(ResponseJson(ApiResponse::success(()))) } pub async fn unlink_workspace( AxumPath(workspace_id): AxumPath, State(deployment): State, ) -> Result>, ApiError> { let client = deployment.remote_client()?; match client.delete_workspace(workspace_id).await { Ok(()) => Ok(ResponseJson(ApiResponse::success(()))), Err(RemoteClientError::Http { status: 404, .. }) => { Ok(ResponseJson(ApiResponse::success(()))) } Err(e) => Err(e.into()), } } pub fn router(deployment: &DeploymentImpl) -> Router { let post_router = Router::new() .route("/", post(link_workspace)) .layer(from_fn_with_state( deployment.clone(), load_workspace_middleware, )); let delete_router = Router::new().route("/", delete(unlink_workspace)); post_router.merge(delete_router) } ================================================ FILE: crates/server/src/routes/workspaces/mod.rs ================================================ pub mod attachments; pub mod codex_setup; pub mod core; pub mod create; pub mod cursor_setup; pub mod execution; pub mod gh_cli_setup; pub mod git; pub mod integration; pub mod links; pub mod pr; pub mod repos; pub mod streams; pub mod workspace_summary; use axum::{ Router, middleware::from_fn_with_state, routing::{get, post}, }; use crate::{DeploymentImpl, middleware::load_workspace_middleware}; pub fn router(deployment: &DeploymentImpl) -> Router { let workspace_id_router = Router::new() .route( "/", get(core::get_workspace) .put(core::update_workspace) .delete(core::delete_workspace), ) .route("/messages/first", get(core::get_first_user_message)) .route("/seen", axum::routing::put(core::mark_seen)) .nest("/git", git::router()) .nest("/execution", execution::router()) .nest("/integration", integration::router()) .nest("/repos", repos::router()) .nest("/pull-requests", pr::router()) .layer(from_fn_with_state( deployment.clone(), load_workspace_middleware, )); let workspaces_router = Router::new() .route( "/", get(core::get_workspaces).post(create::create_workspace), ) .route("/start", post(create::create_and_start_workspace)) .route("/from-pr", post(pr::create_workspace_from_pr)) .route("/streams/ws", get(streams::stream_workspaces_ws)) .route( "/summaries", post(workspace_summary::get_workspace_summaries), ) .nest("/{id}", workspace_id_router) .nest("/{id}/attachments", attachments::router(deployment)) .nest("/{id}/links", links::router(deployment)); Router::new().nest("/workspaces", workspaces_router) } ================================================ FILE: crates/server/src/routes/workspaces/pr.rs ================================================ use std::path::PathBuf; use api_types::{PullRequestStatus, UpsertPullRequestRequest}; use axum::{ Extension, Json, Router, extract::{Query, State}, response::Json as ResponseJson, routing::{get, post}, }; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessRunReason}, merge::{Merge, MergeStatus}, repo::{Repo, RepoError}, session::{CreateSession, Session}, workspace::{CreateWorkspace, Workspace, WorkspaceError}, workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo}, }; use deployment::Deployment; use executors::actions::{ ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, coding_agent_initial::CodingAgentInitialRequest, }; use git::{GitCliError, GitRemote, GitServiceError}; use git_host::{ CreatePrRequest, GitHostError, GitHostProvider, GitHostService, ProviderKind, UnifiedPrComment, github::GhCli, }; use serde::{Deserialize, Serialize}; use services::services::{ config::DEFAULT_PR_DESCRIPTION_PROMPT, container::ContainerService, remote_sync, }; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use workspace_manager::WorkspaceManager; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] pub struct CreatePrApiRequest { pub title: String, pub body: Option, pub target_branch: Option, pub draft: Option, pub repo_id: Uuid, #[serde(default)] pub auto_generate_description: bool, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum PrError { CliNotInstalled { provider: ProviderKind }, CliNotLoggedIn { provider: ProviderKind }, GitCliNotLoggedIn, GitCliNotInstalled, TargetBranchNotFound { branch: String }, UnsupportedProvider, } #[derive(Debug, Serialize, TS)] pub struct AttachPrResponse { pub pr_attached: bool, pub pr_url: Option, pub pr_number: Option, pub pr_status: Option, } #[derive(Debug, Deserialize, Serialize, TS)] pub struct AttachExistingPrRequest { pub repo_id: Uuid, } #[derive(Debug, Serialize, TS)] pub struct PrCommentsResponse { pub comments: Vec, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum GetPrCommentsError { NoPrAttached, CliNotInstalled { provider: ProviderKind }, CliNotLoggedIn { provider: ProviderKind }, } #[derive(Debug, Deserialize, TS)] pub struct GetPrCommentsQuery { pub repo_id: Uuid, } async fn trigger_pr_description_follow_up( deployment: &DeploymentImpl, workspace: &Workspace, pr_number: i64, pr_url: &str, ) -> Result<(), ApiError> { // Get the custom prompt from config, or use default let config = deployment.config().read().await; let prompt_template = config .pr_auto_description_prompt .as_deref() .unwrap_or(DEFAULT_PR_DESCRIPTION_PROMPT); // Replace placeholders in prompt let prompt = prompt_template .replace("{pr_number}", &pr_number.to_string()) .replace("{pr_url}", pr_url); drop(config); // Release the lock before async operations // Get or create a session for this follow-up let session = match Session::find_latest_by_workspace_id(&deployment.db().pool, workspace.id).await? { Some(s) => s, None => { Session::create( &deployment.db().pool, &CreateSession { executor: None, name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; // Get executor profile from the latest coding agent process in this session let Some(executor_profile_id) = ExecutionProcess::latest_executor_profile_for_session(&deployment.db().pool, session.id) .await? else { tracing::warn!( "No executor profile found for session {}, skipping PR description follow-up", session.id ); return Ok(()); }; // Get latest agent turn if one exists (for coding agent continuity) let latest_session_info = CodingAgentTurn::find_latest_session_info(&deployment.db().pool, session.id).await?; let working_dir = session .agent_working_dir .as_ref() .filter(|dir| !dir.is_empty()) .cloned(); // Build the action type (follow-up if session exists, otherwise initial) let action_type = if let Some(info) = latest_session_info { ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { prompt, session_id: info.session_id, reset_to_message_id: None, executor_config: executors::profile::ExecutorConfig::from(executor_profile_id.clone()), working_dir: working_dir.clone(), }) } else { ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { prompt, executor_config: executors::profile::ExecutorConfig::from(executor_profile_id.clone()), working_dir, }) }; let action = ExecutorAction::new(action_type, None); deployment .container() .start_execution( workspace, &session, &action, &ExecutionProcessRunReason::CodingAgent, ) .await?; Ok(()) } pub async fn create_pr( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo_path = repo.path.clone(); let target_branch = if let Some(branch) = request.target_branch { branch } else { workspace_repo.target_branch.clone() }; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; let workspace_path = PathBuf::from(&container_ref); let worktree_path = workspace_path.join(&repo.name); let git = deployment.git(); let push_remote = git.resolve_remote_for_branch(&repo_path, &workspace.branch)?; // Try to get the remote from the branch name (works for remote-tracking branches like "upstream/main"). // Fall back to push_remote if the branch doesn't exist locally or isn't a remote-tracking branch. let (target_remote, base_branch) = match git.get_remote_from_branch_name(&repo_path, &target_branch) { Ok(remote) => { let branch = target_branch .strip_prefix(&format!("{}/", remote.name)) .unwrap_or(&target_branch); (remote, branch.to_string()) } Err(_) => (push_remote.clone(), target_branch.clone()), }; match git.check_remote_branch_exists(&repo_path, &target_remote.url, &base_branch) { Ok(false) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::TargetBranchNotFound { branch: target_branch.clone(), }, ))); } Err(GitServiceError::GitCLI(GitCliError::AuthFailed(_))) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::GitCliNotLoggedIn, ))); } Err(GitServiceError::GitCLI(GitCliError::NotAvailable)) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::GitCliNotInstalled, ))); } Err(e) => return Err(ApiError::GitService(e)), Ok(true) => {} } if let Err(e) = git.push_to_remote(&worktree_path, &workspace.branch, false) { tracing::error!("Failed to push branch to remote: {}", e); match e { GitServiceError::GitCLI(GitCliError::AuthFailed(_)) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::GitCliNotLoggedIn, ))); } GitServiceError::GitCLI(GitCliError::NotAvailable) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::GitCliNotInstalled, ))); } _ => return Err(ApiError::GitService(e)), } } let git_host = match GitHostService::from_url(&target_remote.url) { Ok(host) => host, Err(GitHostError::UnsupportedProvider) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::UnsupportedProvider, ))); } Err(GitHostError::CliNotInstalled { provider }) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::CliNotInstalled { provider }, ))); } Err(e) => return Err(ApiError::GitHost(e)), }; let provider = git_host.provider_kind(); // Create the PR let pr_request = CreatePrRequest { title: request.title.clone(), body: request.body.clone(), head_branch: workspace.branch.clone(), base_branch: base_branch.clone(), draft: request.draft, head_repo_url: Some(push_remote.url.clone()), }; match git_host .create_pr(&repo_path, &target_remote.url, &pr_request) .await { Ok(pr_info) => { // Update the workspace with PR information if let Err(e) = Merge::create_pr( pool, workspace.id, workspace_repo.repo_id, &base_branch, pr_info.number, &pr_info.url, ) .await { tracing::error!("Failed to update workspace PR status: {}", e); } if let Ok(client) = deployment.remote_client() { let request = UpsertPullRequestRequest { url: pr_info.url.clone(), number: pr_info.number as i32, status: PullRequestStatus::Open, merged_at: None, merge_commit_sha: None, target_branch_name: base_branch.clone(), local_workspace_id: workspace.id, }; tokio::spawn(async move { remote_sync::sync_pr_to_remote(&client, request).await; }); } // Auto-open PR in browser if let Err(e) = utils::browser::open_browser(&pr_info.url).await { tracing::warn!("Failed to open PR in browser: {}", e); } deployment .track_if_analytics_allowed( "pr_created", serde_json::json!({ "workspace_id": workspace.id.to_string(), "provider": format!("{:?}", provider), }), ) .await; // Trigger auto-description follow-up if enabled if request.auto_generate_description && let Err(e) = trigger_pr_description_follow_up( &deployment, &workspace, pr_info.number, &pr_info.url, ) .await { tracing::warn!( "Failed to trigger PR description follow-up for attempt {}: {}", workspace.id, e ); } Ok(ResponseJson(ApiResponse::success(pr_info.url))) } Err(e) => { tracing::error!( "Failed to create PR for attempt {} using {:?}: {}", workspace.id, provider, e ); match &e { GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( ApiResponse::error_with_data(PrError::CliNotInstalled { provider: *provider, }), )), GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( PrError::CliNotLoggedIn { provider }, ))), _ => Err(ApiError::GitHost(e)), } } } } pub async fn attach_existing_pr( Extension(workspace): Extension, State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; // Check if PR already attached for this repo let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, request.repo_id).await?; if let Some(Merge::Pr(pr_merge)) = merges.into_iter().next() { return Ok(ResponseJson(ApiResponse::success(AttachPrResponse { pr_attached: true, pr_url: Some(pr_merge.pr_info.url.clone()), pr_number: Some(pr_merge.pr_info.number), pr_status: Some(pr_merge.pr_info.status.clone()), }))); } let git = deployment.git(); let remote = git.resolve_remote_for_branch(&repo.path, &workspace_repo.target_branch)?; let git_host = match GitHostService::from_url(&remote.url) { Ok(host) => host, Err(GitHostError::UnsupportedProvider) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::UnsupportedProvider, ))); } Err(GitHostError::CliNotInstalled { provider }) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::CliNotInstalled { provider }, ))); } Err(e) => return Err(ApiError::GitHost(e)), }; let provider = git_host.provider_kind(); // List all PRs for branch (open, closed, and merged) let prs = match git_host .list_prs_for_branch(&repo.path, &remote.url, &workspace.branch) .await { Ok(prs) => prs, Err(GitHostError::CliNotInstalled { provider }) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::CliNotInstalled { provider }, ))); } Err(GitHostError::AuthFailed(_)) => { return Ok(ResponseJson(ApiResponse::error_with_data( PrError::CliNotLoggedIn { provider }, ))); } Err(e) => return Err(ApiError::GitHost(e)), }; // Take the first PR (prefer open, but also accept merged/closed) if let Some(pr_info) = prs.into_iter().next() { // Save PR info to database let merge = Merge::create_pr( pool, workspace.id, workspace_repo.repo_id, &workspace_repo.target_branch, pr_info.number, &pr_info.url, ) .await?; // Update status if not open if !matches!(pr_info.status, MergeStatus::Open) { Merge::update_status( pool, merge.id, pr_info.status.clone(), pr_info.merge_commit_sha.clone(), ) .await?; } if let Ok(client) = deployment.remote_client() { let pr_status = match pr_info.status { MergeStatus::Open => PullRequestStatus::Open, MergeStatus::Merged => PullRequestStatus::Merged, MergeStatus::Closed => PullRequestStatus::Closed, MergeStatus::Unknown => PullRequestStatus::Open, }; let request = UpsertPullRequestRequest { url: pr_info.url.clone(), number: pr_info.number as i32, status: pr_status, merged_at: None, merge_commit_sha: pr_info.merge_commit_sha.clone(), target_branch_name: workspace_repo.target_branch.clone(), local_workspace_id: workspace.id, }; tokio::spawn(async move { remote_sync::sync_pr_to_remote(&client, request).await; }); } // If PR is merged, archive workspace if matches!(pr_info.status, MergeStatus::Merged) { let open_pr_count = Merge::count_open_prs_for_workspace(pool, workspace.id).await?; if open_pr_count == 0 { if !workspace.pinned && let Err(e) = deployment.container().archive_workspace(workspace.id).await { tracing::error!("Failed to archive workspace {}: {}", workspace.id, e); } } else { tracing::info!( "PR #{} was merged, leaving workspace {} active with {} open PR(s)", pr_info.number, workspace.id, open_pr_count ); } } Ok(ResponseJson(ApiResponse::success(AttachPrResponse { pr_attached: true, pr_url: Some(pr_info.url), pr_number: Some(pr_info.number), pr_status: Some(pr_info.status), }))) } else { Ok(ResponseJson(ApiResponse::success(AttachPrResponse { pr_attached: false, pr_url: None, pr_number: None, pr_status: None, }))) } } pub async fn get_pr_comments( Extension(workspace): Extension, State(deployment): State, Query(query): Query, ) -> Result>, ApiError> { let pool = &deployment.db().pool; // Look up the specific repo using the multi-repo pattern let workspace_repo = WorkspaceRepo::find_by_workspace_and_repo_id(pool, workspace.id, query.repo_id) .await? .ok_or(RepoError::NotFound)?; let repo = Repo::find_by_id(pool, workspace_repo.repo_id) .await? .ok_or(RepoError::NotFound)?; // Find the merge/PR for this specific repo let merges = Merge::find_by_workspace_and_repo_id(pool, workspace.id, query.repo_id).await?; // Ensure there's an attached PR for this repo let pr_info = match merges.into_iter().next() { Some(Merge::Pr(pr_merge)) => pr_merge.pr_info, _ => { return Ok(ResponseJson(ApiResponse::error_with_data( GetPrCommentsError::NoPrAttached, ))); } }; let git = deployment.git(); let remote = git.resolve_remote_for_branch(&repo.path, &workspace_repo.target_branch)?; let git_host = match GitHostService::from_url(&remote.url) { Ok(host) => host, Err(GitHostError::CliNotInstalled { provider }) => { return Ok(ResponseJson(ApiResponse::error_with_data( GetPrCommentsError::CliNotInstalled { provider }, ))); } Err(e) => return Err(ApiError::GitHost(e)), }; let provider = git_host.provider_kind(); match git_host .get_pr_comments(&repo.path, &remote.url, pr_info.number) .await { Ok(comments) => Ok(ResponseJson(ApiResponse::success(PrCommentsResponse { comments, }))), Err(e) => { tracing::error!( "Failed to fetch PR comments for attempt {}, PR #{}: {}", workspace.id, pr_info.number, e ); match &e { GitHostError::CliNotInstalled { provider } => Ok(ResponseJson( ApiResponse::error_with_data(GetPrCommentsError::CliNotInstalled { provider: *provider, }), )), GitHostError::AuthFailed(_) => Ok(ResponseJson(ApiResponse::error_with_data( GetPrCommentsError::CliNotLoggedIn { provider }, ))), _ => Err(ApiError::GitHost(e)), } } } } #[derive(Debug, Serialize, Deserialize, TS)] pub struct CreateWorkspaceFromPrBody { pub repo_id: Uuid, pub pr_number: i64, pub pr_title: String, pub pr_url: String, pub head_branch: String, pub base_branch: String, pub run_setup: bool, pub remote_name: Option, } #[derive(Debug, Serialize, TS)] pub struct CreateWorkspaceFromPrResponse { pub workspace: Workspace, } #[derive(Debug, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum CreateFromPrError { PrNotFound, BranchFetchFailed { message: String }, CliNotInstalled { provider: ProviderKind }, AuthFailed { message: String }, UnsupportedProvider, } /// Best-effort cleanup of partially-created workspace resources. /// Used when workspace creation from PR fails after DB records and filesystem /// resources have already been created. /// /// DB records are deleted synchronously (fast). Filesystem cleanup is spawned /// as a background task to avoid blocking the error response. async fn cleanup_failed_pr_workspace(pool: &sqlx::SqlitePool, workspace: &Workspace) { let workspace_id = workspace.id; // Gather data needed for background filesystem cleanup before deleting DB records let workspace_dir = workspace.container_ref.clone().map(PathBuf::from); let repositories = match WorkspaceRepo::find_repos_for_workspace(pool, workspace_id).await { Ok(repos) => repos, Err(e) => { tracing::warn!( "Failed to find repos for workspace {} during cleanup: {}", workspace_id, e ); vec![] } }; // Delete the workspace — FK CASCADE handles workspace_repos, sessions, merges, etc. if let Err(e) = Workspace::delete(pool, workspace_id).await { tracing::warn!( "Failed to delete workspace {} during cleanup: {}", workspace_id, e ); } // Spawn background cleanup for filesystem resources (worktrees, workspace dir) if let Some(workspace_dir) = workspace_dir { tokio::spawn(async move { if let Err(e) = WorkspaceManager::cleanup_workspace(&workspace_dir, &repositories).await { tracing::error!( "Background cleanup failed for workspace {} at {}: {}", workspace_id, workspace_dir.display(), e ); } }); } } #[axum::debug_handler] pub async fn create_workspace_from_pr( State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let repo = Repo::find_by_id(pool, payload.repo_id) .await? .ok_or(RepoError::NotFound)?; let remote = match payload.remote_name { Some(ref name) => GitRemote { url: deployment.git().get_remote_url(&repo.path, name)?, name: name.clone(), }, None => deployment.git().get_default_remote(&repo.path)?, }; // Use target branch initially - we'll switch to PR branch via gh pr checkout let target_branch_ref = format!("{}/{}", remote.name, payload.base_branch); // Create workspace with target branch initially let workspace_id = Uuid::new_v4(); let mut workspace = Workspace::create( pool, &CreateWorkspace { branch: target_branch_ref.clone(), name: Some(payload.pr_title.clone()), }, workspace_id, ) .await?; WorkspaceRepo::create_many( pool, workspace.id, &[CreateWorkspaceRepo { repo_id: payload.repo_id, target_branch: target_branch_ref.clone(), }], ) .await?; let container_ref = deployment .container() .ensure_container_exists(&workspace) .await?; // Update workspace with container_ref so start_execution can find it workspace.container_ref = Some(container_ref.clone()); // Use gh pr checkout to fetch and switch to the PR branch // This handles SSH/HTTPS auth correctly regardless of fork URL format let worktree_path = PathBuf::from(&container_ref).join(&repo.name); match GhCli::new().get_repo_info(&remote.url, &worktree_path) { Ok(repo_info) => { if let Err(e) = GhCli::new().pr_checkout( &worktree_path, &repo_info.owner, &repo_info.repo_name, payload.pr_number, ) { tracing::error!("Failed to checkout PR branch: {e}"); cleanup_failed_pr_workspace(pool, &workspace).await; return Ok(ResponseJson(ApiResponse::error_with_data( CreateFromPrError::BranchFetchFailed { message: e.to_string(), }, ))); } // Update workspace branch to the actual PR branch Workspace::update_branch_name(pool, workspace.id, &payload.head_branch).await?; workspace.branch = payload.head_branch.clone(); } Err(e) => { tracing::error!( "Failed to get repo info for PR checkout (gh CLI may not be installed): {e}" ); cleanup_failed_pr_workspace(pool, &workspace).await; return Ok(ResponseJson(ApiResponse::error_with_data( CreateFromPrError::BranchFetchFailed { message: format!("Failed to get repository info: {e}"), }, ))); } } Merge::create_pr( pool, workspace.id, payload.repo_id, &format!("{}/{}", remote.name, payload.base_branch), payload.pr_number, &payload.pr_url, ) .await?; if payload.run_setup { let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; if let Some(setup_action) = deployment.container().setup_actions_for_repos(&repos) { let session = Session::create( pool, &CreateSession { executor: None, name: None, }, Uuid::new_v4(), workspace.id, ) .await?; if let Err(e) = deployment .container() .start_execution( &workspace, &session, &setup_action, &ExecutionProcessRunReason::SetupScript, ) .await { tracing::error!("Failed to run setup script: {}", e); } } } deployment .track_if_analytics_allowed( "workspace_created_from_pr", serde_json::json!({ "workspace_id": workspace.id.to_string(), "pr_number": payload.pr_number, "run_setup": payload.run_setup, }), ) .await; tracing::info!( "Created workspace {} from PR #{}", workspace.id, payload.pr_number, ); let workspace = Workspace::find_by_id(pool, workspace.id) .await? .ok_or(WorkspaceError::WorkspaceNotFound)?; Ok(ResponseJson(ApiResponse::success( CreateWorkspaceFromPrResponse { workspace }, ))) } pub fn router() -> Router { Router::new() .route("/", post(create_pr)) .route("/attach", post(attach_existing_pr)) .route("/comments", get(get_pr_comments)) } ================================================ FILE: crates/server/src/routes/workspaces/repos.rs ================================================ use axum::{Extension, Json, Router, extract::State, response::Json as ResponseJson, routing::get}; use db::models::{ requests::WorkspaceRepoInput, workspace::{Workspace, WorkspaceError}, workspace_repo::{RepoWithTargetBranch, WorkspaceRepo}, }; use deployment::Deployment; use serde::{Deserialize, Serialize}; use services::services::container::ContainerService; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; #[derive(Debug, Deserialize, Serialize, TS)] pub struct AddWorkspaceRepoRequest { pub repo_id: Uuid, pub target_branch: String, } #[derive(Debug, Serialize, TS)] pub struct AddWorkspaceRepoResponse { pub workspace: Workspace, pub repo: RepoWithTargetBranch, } pub fn router() -> Router { Router::new().route("/", get(get_workspace_repos).post(add_workspace_repo)) } pub async fn get_workspace_repos( Extension(workspace): Extension, State(deployment): State, ) -> Result>>, ApiError> { let pool = &deployment.db().pool; let repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id).await?; Ok(ResponseJson(ApiResponse::success(repos))) } #[axum::debug_handler] pub async fn add_workspace_repo( Extension(workspace): Extension, State(deployment): State, Json(payload): Json, ) -> Result>, ApiError> { let mut managed_workspace = deployment .workspace_manager() .load_managed_workspace(workspace) .await?; let repo_input = WorkspaceRepoInput { repo_id: payload.repo_id, target_branch: payload.target_branch, }; managed_workspace .add_repository(&repo_input, deployment.git()) .await .map_err(ApiError::from)?; deployment .container() .ensure_container_exists(&managed_workspace.workspace) .await?; let workspace = Workspace::find_by_id(&deployment.db().pool, managed_workspace.workspace.id) .await? .ok_or(WorkspaceError::WorkspaceNotFound)?; let repo = managed_workspace .repos .iter() .find(|repo_with_target| repo_with_target.repo.id == repo_input.repo_id) .cloned() .ok_or_else(|| { ApiError::Conflict("Repository already attached to workspace".to_string()) })?; deployment .track_if_analytics_allowed( "task_attempt_repo_added", serde_json::json!({ "workspace_id": workspace.id.to_string(), "repo_id": repo.repo.id.to_string(), }), ) .await; Ok(ResponseJson(ApiResponse::success( AddWorkspaceRepoResponse { workspace, repo }, ))) } ================================================ FILE: crates/server/src/routes/workspaces/streams.rs ================================================ use axum::{ Extension, extract::{Query, State, ws::Message}, response::IntoResponse, }; use deployment::Deployment; use serde::Deserialize; use services::services::container::ContainerService; use crate::{ DeploymentImpl, routes::relay_ws::{SignedWebSocket, SignedWsUpgrade}, }; #[derive(Debug, Deserialize)] pub struct DiffStreamQuery { #[serde(default)] pub stats_only: bool, } #[derive(Debug, Deserialize)] pub struct WorkspaceStreamQuery { pub archived: Option, pub limit: Option, } pub async fn stream_workspaces_ws( ws: SignedWsUpgrade, Query(query): Query, State(deployment): State, ) -> impl IntoResponse { ws.on_upgrade(move |socket| async move { if let Err(e) = handle_workspaces_ws(socket, deployment, query.archived, query.limit).await { tracing::warn!("workspaces WS closed: {}", e); } }) } pub async fn stream_workspace_diff_ws( ws: SignedWsUpgrade, Query(params): Query, Extension(workspace): Extension, State(deployment): State, ) -> impl IntoResponse { let _ = deployment.container().touch(&workspace).await; let stats_only = params.stats_only; ws.on_upgrade(move |socket| async move { if let Err(e) = handle_workspace_diff_ws(socket, deployment, workspace, stats_only).await { tracing::warn!("diff WS closed: {}", e); } }) } async fn handle_workspace_diff_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, workspace: db::models::workspace::Workspace, stats_only: bool, ) -> anyhow::Result<()> { use futures_util::{StreamExt, TryStreamExt}; use utils::log_msg::LogMsg; let stream = deployment .container() .stream_diff(&workspace, stats_only) .await?; let mut stream = stream.map_ok(|msg: LogMsg| msg.to_ws_message_unchecked()); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("stream error: {}", e); break; } None => break, } } msg = socket.recv() => { match msg { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } async fn handle_workspaces_ws( mut socket: SignedWebSocket, deployment: DeploymentImpl, archived: Option, limit: Option, ) -> anyhow::Result<()> { use futures_util::{StreamExt, TryStreamExt}; let mut stream = deployment .events() .stream_workspaces_raw(archived, limit) .await? .map_ok(|msg| msg.to_ws_message_unchecked()); loop { tokio::select! { item = stream.next() => { match item { Some(Ok(msg)) => { if socket.send(msg).await.is_err() { break; } } Some(Err(e)) => { tracing::error!("stream error: {}", e); break; } None => break, } } msg = socket.recv() => { match msg { Ok(Some(Message::Close(_))) => break, Ok(Some(_)) => {} Ok(None) => break, Err(_) => break, } } } } Ok(()) } ================================================ FILE: crates/server/src/routes/workspaces/workspace_summary.rs ================================================ use std::collections::HashMap; use axum::{Json, extract::State, response::Json as ResponseJson}; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, merge::{Merge, MergeStatus}, workspace::Workspace, }; use deployment::Deployment; use serde::{Deserialize, Serialize}; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError}; /// Request for fetching workspace summaries #[derive(Debug, Deserialize, Serialize, TS)] pub struct WorkspaceSummaryRequest { pub archived: bool, } /// Summary info for a single workspace #[derive(Debug, Serialize, TS)] pub struct WorkspaceSummary { pub workspace_id: Uuid, /// Session ID of the latest execution process pub latest_session_id: Option, /// Is a tool approval currently pending? pub has_pending_approval: bool, /// Number of files with changes pub files_changed: Option, /// Total lines added across all files pub lines_added: Option, /// Total lines removed across all files pub lines_removed: Option, /// When the latest execution process completed #[ts(optional)] pub latest_process_completed_at: Option>, /// Status of the latest execution process pub latest_process_status: Option, /// Is a dev server currently running? pub has_running_dev_server: bool, /// Does this workspace have unseen coding agent turns? pub has_unseen_turns: bool, /// PR status for this workspace (if any PR exists) pub pr_status: Option, /// PR number for this workspace (if any PR exists) pub pr_number: Option, /// PR URL for this workspace (if any PR exists) pub pr_url: Option, } /// Response containing summaries for requested workspaces #[derive(Debug, Serialize, TS)] pub struct WorkspaceSummaryResponse { pub summaries: Vec, } #[derive(Debug, Clone, Default, Serialize, TS)] pub struct DiffStats { pub files_changed: usize, pub lines_added: usize, pub lines_removed: usize, } /// Fetch summary information for workspaces filtered by archived status. /// This endpoint returns data that cannot be efficiently included in the streaming endpoint. #[axum::debug_handler] pub async fn get_workspace_summaries( State(deployment): State, Json(request): Json, ) -> Result>, ApiError> { let pool = &deployment.db().pool; let archived = request.archived; // 1. Fetch all workspaces with the given archived status let workspaces: Vec = Workspace::find_all_with_status(pool, Some(archived), None) .await? .into_iter() .map(|ws| ws.workspace) .collect(); if workspaces.is_empty() { return Ok(ResponseJson(ApiResponse::success( WorkspaceSummaryResponse { summaries: vec![] }, ))); } // 2. Fetch latest process info for workspaces with this archived status let latest_processes = ExecutionProcess::find_latest_for_workspaces(pool, archived).await?; // 3. Check which workspaces have running dev servers let dev_server_workspaces = ExecutionProcess::find_workspaces_with_running_dev_servers(pool, archived).await?; // 4. Check pending approvals for running processes let running_ep_ids: Vec<_> = latest_processes .values() .filter(|info| info.status == ExecutionProcessStatus::Running) .map(|info| info.execution_process_id) .collect(); let pending_approval_eps = deployment .approvals() .get_pending_execution_process_ids(&running_ep_ids); // 5. Check which workspaces have unseen coding agent turns let unseen_workspaces = CodingAgentTurn::find_workspaces_with_unseen(pool, archived).await?; // 6. Get PR status for each workspace let pr_statuses = Merge::get_latest_pr_status_for_workspaces(pool, archived).await?; // 7. Compute diff stats for each workspace (in parallel) let diff_futures: Vec<_> = workspaces .iter() .map(|ws| { let workspace = ws.clone(); let deployment = deployment.clone(); async move { if workspace.container_ref.is_some() { compute_workspace_diff_stats(&deployment, &workspace) .await .map(|stats| (workspace.id, stats)) } else { None } } }) .collect(); let diff_results: Vec> = futures_util::future::join_all(diff_futures).await; let diff_stats: HashMap = diff_results.into_iter().flatten().collect(); // 8. Assemble response let summaries: Vec = workspaces .iter() .map(|ws| { let id = ws.id; let latest = latest_processes.get(&id); let has_pending = latest .map(|p| pending_approval_eps.contains(&p.execution_process_id)) .unwrap_or(false); let stats = diff_stats.get(&id); WorkspaceSummary { workspace_id: id, latest_session_id: latest.map(|p| p.session_id), has_pending_approval: has_pending, files_changed: stats.map(|s| s.files_changed), lines_added: stats.map(|s| s.lines_added), lines_removed: stats.map(|s| s.lines_removed), latest_process_completed_at: latest.and_then(|p| p.completed_at), latest_process_status: latest.map(|p| p.status.clone()), has_running_dev_server: dev_server_workspaces.contains(&id), has_unseen_turns: unseen_workspaces.contains(&id), pr_status: pr_statuses.get(&id).map(|pr| pr.pr_info.status.clone()), pr_number: pr_statuses.get(&id).map(|pr| pr.pr_info.number), pr_url: pr_statuses.get(&id).map(|pr| pr.pr_info.url.clone()), } }) .collect(); Ok(ResponseJson(ApiResponse::success( WorkspaceSummaryResponse { summaries }, ))) } /// Compute diff stats for a workspace. pub async fn compute_workspace_diff_stats( deployment: &DeploymentImpl, workspace: &Workspace, ) -> Option { let stats = services::services::diff_stream::compute_diff_stats( &deployment.db().pool, deployment.git(), workspace, ) .await?; Some(DiffStats { files_changed: stats.files_changed, lines_added: stats.lines_added, lines_removed: stats.lines_removed, }) } ================================================ FILE: crates/server/src/startup.rs ================================================ use std::{ collections::HashSet, fs, io, path::{Path, PathBuf}, }; use deployment::{Deployment, DeploymentError}; use services::services::container::ContainerService; use tokio_util::sync::CancellationToken; use utils::assets::asset_dir; use crate::{DeploymentImpl, tunnel}; /// A running server instance. Callers can read the port, then call `serve()` /// to run the server until the shutdown token is cancelled. pub struct ServerHandle { pub port: u16, pub proxy_port: u16, pub deployment: DeploymentImpl, shutdown_token: CancellationToken, main_listener: tokio::net::TcpListener, proxy_listener: tokio::net::TcpListener, } impl ServerHandle { /// The base URL the main server is listening on. /// /// Uses `localhost` rather than `127.0.0.1` so that macOS ATS /// (App Transport Security) exception domains apply correctly in /// the Tauri desktop app — IP address literals aren't reliably /// matched by ATS, which causes WebSocket connections to fail. pub fn url(&self) -> String { format!("http://localhost:{}", self.port) } /// Run both the main and proxy servers until the shutdown token is cancelled. pub async fn serve(self) -> anyhow::Result<()> { // Start relay tunnel so the host registers with the relay server. // This must happen after the port is known (it's needed for local // proxying) and is shared between the standalone binary and Tauri. self.deployment.server_info().set_port(self.port).await; self.deployment .server_info() .set_bind_ip(self.main_listener.local_addr()?.ip()) .await; let relay_host_name = { let config = self.deployment.config().read().await; tunnel::effective_relay_host_name(&config, self.deployment.user_id()) }; self.deployment .server_info() .set_hostname(relay_host_name) .await; tunnel::spawn_relay(&self.deployment).await; let app_router = crate::routes::router(self.deployment.clone()); let proxy_router: axum::Router = crate::preview_proxy::router(); let main_shutdown = self.shutdown_token.clone(); let proxy_shutdown = self.shutdown_token.clone(); let main_server = axum::serve(self.main_listener, app_router) .with_graceful_shutdown(async move { main_shutdown.cancelled().await }); let proxy_server = axum::serve(self.proxy_listener, proxy_router) .with_graceful_shutdown(async move { proxy_shutdown.cancelled().await }); let main_handle = tokio::spawn(async move { if let Err(e) = main_server.await { tracing::error!("Main server error: {}", e); } }); let proxy_handle = tokio::spawn(async move { if let Err(e) = proxy_server.await { tracing::error!("Preview proxy error: {}", e); } }); tokio::select! { _ = main_handle => {} _ = proxy_handle => {} } perform_cleanup_actions(&self.deployment).await; Ok(()) } /// Return a clone of the shutdown token. Cancel it to stop `serve()`. pub fn shutdown_token(&self) -> CancellationToken { self.shutdown_token.clone() } } /// Initialize the deployment, bind listeners on `localhost` with OS-assigned /// ports, and return a handle that is ready to serve. /// /// Uses `localhost` rather than `127.0.0.1` so the bind address matches /// the hostname the frontend connects to. On modern macOS, `localhost` /// resolves to `::1` (IPv6) first — binding to `127.0.0.1` (IPv4) while /// the browser connects via `::1` causes "connection refused". pub async fn start() -> anyhow::Result { start_with_bind("localhost:0", "localhost:0").await } /// Like [`start`], but lets the caller specify the bind addresses for the main /// server and the preview proxy (e.g. `"0.0.0.0:8080"`). pub async fn start_with_bind(main_addr: &str, proxy_addr: &str) -> anyhow::Result { let deployment = initialize_deployment().await?; let listener = tokio::net::TcpListener::bind(main_addr).await?; let port = listener.local_addr()?.port(); let proxy_listener = tokio::net::TcpListener::bind(proxy_addr).await?; let proxy_port = proxy_listener.local_addr()?.port(); crate::preview_proxy::set_proxy_port(proxy_port); tracing::info!("Server on :{port}, Preview proxy on :{proxy_port}"); Ok(ServerHandle { port, proxy_port, deployment, shutdown_token: CancellationToken::new(), main_listener: listener, proxy_listener, }) } /// Initialize the deployment: create asset directory, run migrations, backfill data, /// and pre-warm caches. Shared between the standalone server and the Tauri app. pub async fn initialize_deployment() -> Result { // Create asset directory if it doesn't exist if !asset_dir().exists() { std::fs::create_dir_all(asset_dir()).map_err(|e| { DeploymentError::Other(anyhow::anyhow!("Failed to create asset directory: {}", e)) })?; } // Copy old database to new location for safe downgrades let old_db = asset_dir().join("db.sqlite"); let new_db = asset_dir().join("db.v2.sqlite"); if !new_db.exists() && old_db.exists() { tracing::info!( "Copying database to new location: {:?} -> {:?}", old_db, new_db ); std::fs::copy(&old_db, &new_db).expect("Failed to copy database file"); tracing::info!("Database copy complete"); } let deployment = DeploymentImpl::new().await?; migrate_legacy_attachment_directories(&deployment).await?; deployment.update_sentry_scope().await?; deployment .container() .cleanup_orphan_executions() .await .map_err(DeploymentError::from)?; deployment .container() .backfill_before_head_commits() .await .map_err(DeploymentError::from)?; deployment .container() .backfill_repo_names() .await .map_err(DeploymentError::from)?; deployment .track_if_analytics_allowed("session_start", serde_json::json!({})) .await; // Preload global executor options cache for all executors with DEFAULT presets tokio::spawn(async move { executors::executors::utils::preload_global_executor_options_cache().await; }); Ok(deployment) } /// Gracefully shut down running execution processes. pub async fn perform_cleanup_actions(deployment: &DeploymentImpl) { deployment .container() .kill_all_running_processes() .await .expect("Failed to cleanly kill running execution processes"); } const LEGACY_ATTACHMENT_MIGRATION_MARKER: &str = ".attachment-directories-migrated-v1"; #[derive(Default)] struct DirectoryMigrationStats { moved_files: u64, removed_duplicates: u64, created_directories: u64, failures: u64, } impl DirectoryMigrationStats { fn merge(&mut self, other: DirectoryMigrationStats) { self.moved_files += other.moved_files; self.removed_duplicates += other.removed_duplicates; self.created_directories += other.created_directories; self.failures += other.failures; } } async fn migrate_legacy_attachment_directories( deployment: &DeploymentImpl, ) -> Result<(), DeploymentError> { let marker_path = asset_dir().join(LEGACY_ATTACHMENT_MIGRATION_MARKER); if marker_path.exists() { return Ok(()); } let mut stats = DirectoryMigrationStats::default(); let cache_root = utils::cache_dir(); stats.merge(migrate_legacy_directory( &cache_root.join("images"), &cache_root.join("attachments"), false, )); for base_path in collect_attachment_migration_paths(deployment).await? { stats.merge(migrate_legacy_directory( &base_path.join(".vibe-images"), &base_path.join(utils::path::VIBE_ATTACHMENTS_DIR), true, )); } if stats.failures == 0 { fs::write(&marker_path, b"ok")?; tracing::info!( "Legacy attachment directory migration completed: moved {}, removed duplicates {}, created directories {}", stats.moved_files, stats.removed_duplicates, stats.created_directories ); } else { tracing::warn!( "Legacy attachment directory migration completed with {} failures; will retry on next startup", stats.failures ); } Ok(()) } async fn collect_attachment_migration_paths( deployment: &DeploymentImpl, ) -> Result, DeploymentError> { use db::models::{session::Session, workspace::Workspace, workspace_repo::WorkspaceRepo}; let workspaces = Workspace::fetch_all(&deployment.db().pool).await?; let mut paths = HashSet::new(); for workspace in workspaces { let Some(container_ref) = workspace.container_ref.as_deref() else { continue; }; if container_ref.is_empty() { continue; } let workspace_root = PathBuf::from(container_ref); paths.insert(workspace_root.clone()); for repo in WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await? { let repo_base = match repo.default_working_dir.as_deref() { Some(default_dir) if !default_dir.is_empty() => { workspace_root.join(&repo.name).join(default_dir) } _ => workspace_root.join(&repo.name), }; paths.insert(repo_base); } for session in Session::find_by_workspace_id(&deployment.db().pool, workspace.id).await? { let base_path = match session.agent_working_dir.as_deref() { Some(dir) if !dir.is_empty() => workspace_root.join(dir), _ => workspace_root.clone(), }; paths.insert(base_path); } } let mut paths = paths.into_iter().collect::>(); paths.sort(); Ok(paths) } fn migrate_legacy_directory( src_dir: &Path, dst_dir: &Path, ensure_gitignore: bool, ) -> DirectoryMigrationStats { let mut stats = DirectoryMigrationStats::default(); if !src_dir.exists() { return stats; } if let Err(error) = fs::create_dir_all(dst_dir) { tracing::warn!( "Failed to create attachment directory {}: {}", dst_dir.display(), error ); stats.failures += 1; return stats; } stats.created_directories += 1; if let Err(error) = migrate_directory_contents(src_dir, dst_dir, ensure_gitignore, &mut stats) { tracing::warn!( "Failed to migrate legacy attachment directory {} -> {}: {}", src_dir.display(), dst_dir.display(), error ); stats.failures += 1; } if ensure_gitignore && let Err(error) = ensure_attachments_gitignore(dst_dir) { tracing::warn!( "Failed to ensure .gitignore in {}: {}", dst_dir.display(), error ); stats.failures += 1; } if let Err(error) = remove_empty_dir_tree(src_dir) { tracing::warn!( "Failed to clean up legacy attachment directory {}: {}", src_dir.display(), error ); stats.failures += 1; } stats } fn migrate_directory_contents( src_dir: &Path, dst_dir: &Path, ensure_gitignore: bool, stats: &mut DirectoryMigrationStats, ) -> io::Result<()> { for entry in fs::read_dir(src_dir)? { let entry = entry?; let src_path = entry.path(); let file_name = entry.file_name(); if ensure_gitignore && file_name == ".gitignore" { continue; } let dst_path = dst_dir.join(&file_name); let file_type = entry.file_type()?; if file_type.is_dir() { fs::create_dir_all(&dst_path)?; migrate_directory_contents(&src_path, &dst_path, false, stats)?; remove_empty_dir_tree(&src_path)?; continue; } if dst_path.exists() { fs::remove_file(&src_path)?; stats.removed_duplicates += 1; continue; } move_path(&src_path, &dst_path)?; stats.moved_files += 1; } Ok(()) } fn move_path(src_path: &Path, dst_path: &Path) -> io::Result<()> { match fs::rename(src_path, dst_path) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::CrossesDevices => { fs::copy(src_path, dst_path)?; fs::remove_file(src_path) } Err(error) => Err(error), } } fn ensure_attachments_gitignore(dir: &Path) -> io::Result<()> { let gitignore_path = dir.join(".gitignore"); if !gitignore_path.exists() { fs::write(gitignore_path, "*\n")?; } Ok(()) } fn remove_empty_dir_tree(path: &Path) -> io::Result<()> { if !path.exists() { return Ok(()); } if fs::read_dir(path)?.next().is_none() { fs::remove_dir(path)?; } Ok(()) } #[cfg(test)] mod tests { use tempfile::TempDir; use super::*; #[test] fn migrates_legacy_cache_directory_contents() { let temp_dir = TempDir::new().unwrap(); let src = temp_dir.path().join("images"); let dst = temp_dir.path().join("attachments"); fs::create_dir_all(&src).unwrap(); fs::write(src.join("asset.png"), b"hello").unwrap(); let stats = migrate_legacy_directory(&src, &dst, false); assert_eq!(stats.moved_files, 1); assert!(dst.join("asset.png").exists()); assert!(!src.exists()); } #[test] fn removes_legacy_duplicates_when_destination_exists() { let temp_dir = TempDir::new().unwrap(); let src = temp_dir.path().join(".vibe-images"); let dst = temp_dir.path().join(".vibe-attachments"); fs::create_dir_all(&src).unwrap(); fs::create_dir_all(&dst).unwrap(); fs::write(src.join("asset.png"), b"old").unwrap(); fs::write(dst.join("asset.png"), b"new").unwrap(); let stats = migrate_legacy_directory(&src, &dst, true); assert_eq!(stats.removed_duplicates, 1); assert_eq!(fs::read(dst.join("asset.png")).unwrap(), b"new"); assert!(!src.exists()); } #[test] fn ensures_gitignore_for_workspace_attachment_dir() { let temp_dir = TempDir::new().unwrap(); let src = temp_dir.path().join(".vibe-images"); let dst = temp_dir.path().join(".vibe-attachments"); fs::create_dir_all(&src).unwrap(); fs::write(src.join("file.pdf"), b"attachment").unwrap(); migrate_legacy_directory(&src, &dst, true); assert_eq!(fs::read_to_string(dst.join(".gitignore")).unwrap(), "*\n"); assert!(dst.join("file.pdf").exists()); } } ================================================ FILE: crates/server/src/tunnel.rs ================================================ //! Relay client bootstrap for remote access to the local server. //! //! App-specific concerns (login, host lifecycle) stay here. The transport and //! muxing implementation lives in the `relay-tunnel` crate. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use anyhow::Context as _; use deployment::Deployment as _; use relay_tunnel::client::{RelayClientConfig, start_relay_client}; use services::services::{config::Config, remote_client::RemoteClient}; use crate::DeploymentImpl; const RELAY_RECONNECT_INITIAL_DELAY_SECS: u64 = 1; const RELAY_RECONNECT_MAX_DELAY_SECS: u64 = 30; pub fn default_relay_host_name(user_id: &str) -> String { let os_type = os_info::get().os_type().to_string(); format!("{os_type} host ({user_id})") } pub fn effective_relay_host_name(config: &Config, user_id: &str) -> String { config .relay_host_name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) .map(str::to_string) .unwrap_or_else(|| default_relay_host_name(user_id)) } fn relay_api_base() -> Option { std::env::var("VK_SHARED_RELAY_API_BASE") .ok() .or_else(|| option_env!("VK_SHARED_RELAY_API_BASE").map(|s| s.to_string())) } struct RelayParams { local_port: u16, local_bind_ip: IpAddr, remote_client: RemoteClient, relay_base: String, machine_id: String, host_name: String, } /// Resolve all preconditions for starting the relay. Returns `None` if any /// requirement is missing (config, env, login, server info). async fn resolve_relay_params(deployment: &DeploymentImpl) -> Option { let config = deployment.config().read().await; if !config.relay_enabled { tracing::info!("Relay disabled by config"); return None; } drop(config); let relay_base = relay_api_base().or_else(|| { tracing::debug!("VK_SHARED_RELAY_API_BASE not set; relay unavailable"); None })?; let remote_client = deployment.remote_client().ok().or_else(|| { tracing::debug!("Remote client not configured; relay unavailable"); None })?; let login_status = deployment.get_login_status().await; if matches!(login_status, api_types::LoginStatus::LoggedOut) { tracing::info!("Not logged in; relay will start on login"); return None; } let local_port = deployment.server_info().get_port().await.or_else(|| { tracing::warn!("Relay local port not set; cannot spawn relay"); None })?; let local_bind_ip = deployment.server_info().get_bind_ip().await.or_else(|| { tracing::warn!("Relay local bind IP not set; cannot spawn relay"); None })?; let host_name = deployment.server_info().get_hostname().await.or_else(|| { tracing::warn!("Server hostname not set; cannot spawn relay"); None })?; Some(RelayParams { local_port, local_bind_ip, remote_client, relay_base, machine_id: deployment.user_id().to_string(), host_name, }) } /// Spawn the relay reconnect loop. Safe to call multiple times — cancels any /// previous session first via `RelayControl::reset`. pub async fn spawn_relay(deployment: &DeploymentImpl) { let Some(params) = resolve_relay_params(deployment).await else { return; }; let cancel_token = deployment.relay_control().reset().await; tokio::spawn(async move { tracing::info!("Relay auto-reconnect loop started"); let mut delay = std::time::Duration::from_secs(RELAY_RECONNECT_INITIAL_DELAY_SECS); let max_delay = std::time::Duration::from_secs(RELAY_RECONNECT_MAX_DELAY_SECS); while !cancel_token.is_cancelled() && let Err(error) = start_relay(¶ms, cancel_token.clone()).await { tracing::debug!( ?error, retry_in_secs = delay.as_secs(), "Relay connection failed; retrying" ); tokio::select! { _ = cancel_token.cancelled() => break, _ = tokio::time::sleep(delay) => {} } delay = std::cmp::min(delay.saturating_mul(2), max_delay); } tracing::info!("Relay reconnect loop exited"); }); } /// Stop the relay by cancelling the current session token. pub async fn stop_relay(deployment: &DeploymentImpl) { deployment.relay_control().stop().await; tracing::info!("Relay stopped"); } /// Start the relay client transport. async fn start_relay( params: &RelayParams, shutdown: tokio_util::sync::CancellationToken, ) -> anyhow::Result<()> { let base_url = params.relay_base.trim_end_matches('/'); let encoded_name = url::form_urlencoded::Serializer::new(String::new()) .append_pair("machine_id", ¶ms.machine_id) .append_pair("name", ¶ms.host_name) .append_pair("agent_version", env!("CARGO_PKG_VERSION")) .finish(); let ws_url = if let Some(rest) = base_url.strip_prefix("https://") { format!("wss://{rest}/v1/relay/connect?{encoded_name}") } else if let Some(rest) = base_url.strip_prefix("http://") { format!("ws://{rest}/v1/relay/connect?{encoded_name}") } else { anyhow::bail!("Unexpected base URL scheme: {base_url}"); }; let access_token = params .remote_client .access_token() .await .context("Failed to get access token for relay")?; tracing::info!(%ws_url, "Connecting relay control channel"); let local_addr = relay_local_addr(params.local_bind_ip, params.local_port); start_relay_client(RelayClientConfig { ws_url, bearer_token: access_token, local_addr: local_addr.to_string(), shutdown, }) .await } fn relay_local_addr(bind_ip: IpAddr, port: u16) -> SocketAddr { SocketAddr::new(normalize_relay_bind_ip(bind_ip), port) } fn normalize_relay_bind_ip(bind_ip: IpAddr) -> IpAddr { match bind_ip { IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST), ip => ip, } } #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use super::{normalize_relay_bind_ip, relay_local_addr}; #[test] fn relay_local_addr_keeps_ipv4_loopback() { let bind_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); let local_addr = relay_local_addr(bind_ip, 8080); assert_eq!(local_addr, SocketAddr::new(bind_ip, 8080)); } #[test] fn relay_local_addr_keeps_ipv6_loopback() { let bind_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); let local_addr = relay_local_addr(bind_ip, 8080); assert_eq!(local_addr, SocketAddr::new(bind_ip, 8080)); } #[test] fn relay_local_addr_maps_unspecified_ipv4_to_loopback() { let local_addr = relay_local_addr(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8080); assert_eq!( local_addr, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) ); } #[test] fn relay_local_addr_maps_unspecified_ipv6_to_loopback() { let local_addr = relay_local_addr(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 8080); assert_eq!( local_addr, SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080) ); } #[test] fn normalize_relay_bind_ip_preserves_non_wildcard_addresses() { let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)); let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)); assert_eq!(normalize_relay_bind_ip(ipv4), ipv4); assert_eq!(normalize_relay_bind_ip(ipv6), ipv6); } } ================================================ FILE: crates/server-info/Cargo.toml ================================================ [package] name = "server-info" version = "0.1.33" edition = "2024" [dependencies] tokio = { workspace = true } ================================================ FILE: crates/server-info/src/lib.rs ================================================ use std::net::IpAddr; use tokio::sync::RwLock; /// Runtime information about the local server (port, hostname). pub struct ServerInfo { port: RwLock>, bind_ip: RwLock>, hostname: RwLock>, } impl Default for ServerInfo { fn default() -> Self { Self::new() } } impl ServerInfo { pub fn new() -> Self { Self { port: RwLock::new(None), bind_ip: RwLock::new(None), hostname: RwLock::new(None), } } pub async fn set_port(&self, port: u16) { *self.port.write().await = Some(port); } pub async fn get_port(&self) -> Option { *self.port.read().await } pub async fn set_bind_ip(&self, bind_ip: IpAddr) { *self.bind_ip.write().await = Some(bind_ip); } pub async fn get_bind_ip(&self) -> Option { *self.bind_ip.read().await } pub async fn set_hostname(&self, hostname: String) { *self.hostname.write().await = Some(hostname); } pub async fn get_hostname(&self) -> Option { self.hostname.read().await.clone() } } ================================================ FILE: crates/services/Cargo.toml ================================================ [package] name = "services" version = "0.1.33" edition = "2024" [features] default = [] cloud = [] qa-mode = ["executors/qa-mode"] [dependencies] indicatif = "0.17" api-types = { path = "../api-types" } utils = { path = "../utils" } git = { path = "../git" } git-host = { path = "../git-host" } executors = { path = "../executors" } db = { path = "../db" } worktree-manager = { path = "../worktree-manager" } tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } serde = { workspace = true } serde_json = { workspace = true } url = "2.5" anyhow = { workspace = true } tracing = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } dirs = "5.0" git2 = { workspace = true } async-trait = { workspace = true } rust-embed = "8.2" ignore = "0.4" notify-rust = "4.11" os_info = "3.12.0" reqwest = { workspace = true } json-patch = "2.0" backon = "1.5.1" thiserror = { workspace = true } futures = "0.3.31" tokio-stream = "0.1.17" strum = "0.27.2" strum_macros = "0.27.2" notify = "8.2.0" notify-debouncer-full = "0.5.0" dunce = "1.0" dashmap = "6.1" once_cell = "1.20" sha2 = "0.10" fst = "0.4" moka = { version = "0.12", features = ["future"] } mime_guess = "2.0" [dev-dependencies] tempfile = "3" ================================================ FILE: crates/services/src/lib.rs ================================================ pub mod services; pub use services::remote_client::{HandoffErrorCode, RemoteClient, RemoteClientError}; ================================================ FILE: crates/services/src/services/analytics.rs ================================================ use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, time::Duration, }; use os_info; use serde_json::{Value, json}; #[derive(Debug, Clone)] pub struct AnalyticsContext { pub user_id: String, pub analytics_service: AnalyticsService, } #[derive(Debug, Clone)] pub struct AnalyticsConfig { pub posthog_api_key: String, pub posthog_api_endpoint: String, } impl AnalyticsConfig { pub fn new() -> Option { let api_key = option_env!("POSTHOG_API_KEY") .map(|s| s.to_string()) .or_else(|| std::env::var("POSTHOG_API_KEY").ok())?; let api_endpoint = option_env!("POSTHOG_API_ENDPOINT") .map(|s| s.to_string()) .or_else(|| std::env::var("POSTHOG_API_ENDPOINT").ok())?; Some(Self { posthog_api_key: api_key, posthog_api_endpoint: api_endpoint, }) } } #[derive(Clone, Debug)] pub struct AnalyticsService { config: AnalyticsConfig, client: reqwest::Client, } impl AnalyticsService { pub fn new(config: AnalyticsConfig) -> Self { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap(); Self { config, client } } pub fn track_event(&self, user_id: &str, event_name: &str, properties: Option) { let endpoint = format!( "{}/capture/", self.config.posthog_api_endpoint.trim_end_matches('/') ); let mut payload = json!({ "api_key": self.config.posthog_api_key, "event": event_name, "distinct_id": user_id, }); if event_name == "$identify" { // For $identify, set person properties in $set if let Some(props) = properties { payload["$set"] = props; } } else { // For other events, use properties as before let mut event_properties = properties.unwrap_or_else(|| json!({})); if let Some(props) = event_properties.as_object_mut() { props.insert( "timestamp".to_string(), json!(chrono::Utc::now().to_rfc3339()), ); props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); props.insert("device".to_string(), get_device_info()); props.insert("source".to_string(), json!("backend")); } payload["properties"] = event_properties; } let client = self.client.clone(); let event_name = event_name.to_string(); tokio::spawn(async move { match client .post(&endpoint) .header("Content-Type", "application/json") .json(&payload) .send() .await { Ok(response) => { if response.status().is_success() { tracing::debug!("Event '{}' sent successfully", event_name); } else { let status = response.status(); let response_text = response.text().await.unwrap_or_default(); tracing::error!( "Failed to send event. Status: {}. Response: {}", status, response_text ); } } Err(e) => { tracing::error!("Error sending event '{}': {}", event_name, e); } } }); } } /// Generates a consistent, anonymous user ID for npm package telemetry. /// Returns a hex string prefixed with "npm_user_" pub fn generate_user_id() -> String { let mut hasher = DefaultHasher::new(); #[cfg(target_os = "macos")] { // Use ioreg to get hardware UUID if let Ok(output) = std::process::Command::new("ioreg") .args(["-rd1", "-c", "IOPlatformExpertDevice"]) .output() { let stdout = String::from_utf8_lossy(&output.stdout); if let Some(line) = stdout.lines().find(|l| l.contains("IOPlatformUUID")) { line.hash(&mut hasher); } } } #[cfg(target_os = "linux")] { if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { machine_id.trim().hash(&mut hasher); } } #[cfg(target_os = "windows")] { use utils::command_ext::NoWindowExt; // Use PowerShell to get machine GUID from registry if let Ok(output) = std::process::Command::new("powershell") .args(&[ "-NoProfile", "-Command", "(Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid", ]) .no_window() .output() { if output.status.success() { output.stdout.hash(&mut hasher); } } } // Add username for per-user differentiation if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) { user.hash(&mut hasher); } // Add home directory for additional entropy if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { home.hash(&mut hasher); } format!("npm_user_{:016x}", hasher.finish()) } fn get_device_info() -> Value { let info = os_info::get(); json!({ "os_type": info.os_type().to_string(), "os_version": info.version().to_string(), "architecture": info.architecture().unwrap_or("unknown").to_string(), "bitness": info.bitness().to_string(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_user_id_format() { let id = generate_user_id(); assert!(id.starts_with("npm_user_")); assert_eq!(id.len(), 25); } #[test] fn test_consistency() { let id1 = generate_user_id(); let id2 = generate_user_id(); assert_eq!(id1, id2, "ID should be consistent across calls"); } } ================================================ FILE: crates/services/src/services/approvals/executor_approvals.rs ================================================ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use db::{self, DBService, models::execution_process::ExecutionProcess}; use executors::approvals::{ExecutorApprovalError, ExecutorApprovalService}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use utils::approvals::{ApprovalOutcome, ApprovalRequest, ApprovalStatus, QuestionStatus}; use uuid::Uuid; use crate::services::{approvals::Approvals, notification::NotificationService}; type ApprovalWaiter = futures::future::Shared>; pub struct ExecutorApprovalBridge { approvals: Approvals, db: DBService, notification_service: NotificationService, execution_process_id: Uuid, /// Waiters stored between create and wait phases, keyed by approval_id. waiters: Mutex>, } impl ExecutorApprovalBridge { pub fn new( approvals: Approvals, db: DBService, notification_service: NotificationService, execution_process_id: Uuid, ) -> Arc { Arc::new(Self { approvals, db, notification_service, execution_process_id, waiters: Mutex::new(HashMap::new()), }) } async fn create_internal( &self, tool_name: &str, is_question: bool, question_count: Option, ) -> Result { let request = ApprovalRequest::new(tool_name.to_string(), self.execution_process_id); let (request, waiter) = self .approvals .create_with_waiter(request, is_question) .await .map_err(ExecutorApprovalError::request_failed)?; let approval_id = request.id.clone(); // Store waiter for the wait phase self.waiters .lock() .await .insert(approval_id.clone(), waiter); let (workspace_name, workspace_id) = ExecutionProcess::load_context(&self.db.pool, self.execution_process_id) .await .map(|ctx| { let name = ctx .workspace .name .unwrap_or_else(|| ctx.workspace.branch.clone()); (name, Some(ctx.workspace.id)) }) .unwrap_or_else(|_| ("Unknown workspace".to_string(), None)); let (title, message) = if let Some(count) = question_count { if count == 1 { ( format!("Question Asked: {}", workspace_name), "1 question requires an answer".to_string(), ) } else { ( format!("Question Asked: {}", workspace_name), format!("{} questions require answers", count), ) } } else { ( format!("Approval Needed: {}", workspace_name), format!("Tool '{}' requires approval", tool_name), ) }; self.notification_service .notify(&title, &message, workspace_id) .await; Ok(approval_id) } async fn wait_internal( &self, approval_id: &str, cancel: CancellationToken, ) -> Result { let waiter = self .waiters .lock() .await .remove(approval_id) .ok_or_else(|| { ExecutorApprovalError::request_failed(format!( "no waiter found for approval_id={}", approval_id )) })?; let outcome = tokio::select! { _ = cancel.cancelled() => { tracing::info!("Approval request cancelled for approval_id={}", approval_id); self.approvals.cancel(approval_id).await; return Err(ExecutorApprovalError::Cancelled); } outcome = waiter => outcome, }; Ok(outcome) } } #[async_trait] impl ExecutorApprovalService for ExecutorApprovalBridge { async fn create_tool_approval(&self, tool_name: &str) -> Result { self.create_internal(tool_name, false, None).await } async fn create_question_approval( &self, tool_name: &str, question_count: usize, ) -> Result { self.create_internal(tool_name, true, Some(question_count)) .await } async fn wait_tool_approval( &self, approval_id: &str, cancel: CancellationToken, ) -> Result { let outcome = self.wait_internal(approval_id, cancel).await?; match outcome { ApprovalOutcome::Approved => Ok(ApprovalStatus::Approved), ApprovalOutcome::Denied { reason } => Ok(ApprovalStatus::Denied { reason }), ApprovalOutcome::TimedOut => Ok(ApprovalStatus::TimedOut), ApprovalOutcome::Answered { .. } => Err(ExecutorApprovalError::request_failed( "unexpected question response for permission request", )), } } async fn wait_question_answer( &self, approval_id: &str, cancel: CancellationToken, ) -> Result { let outcome = self.wait_internal(approval_id, cancel).await?; match outcome { ApprovalOutcome::Answered { answers } => Ok(QuestionStatus::Answered { answers }), ApprovalOutcome::TimedOut => Ok(QuestionStatus::TimedOut), ApprovalOutcome::Approved | ApprovalOutcome::Denied { .. } => { Err(ExecutorApprovalError::request_failed( "unexpected permission response for question request", )) } } } } ================================================ FILE: crates/services/src/services/approvals.rs ================================================ pub mod executor_approvals; use std::{collections::HashSet, sync::Arc, time::Duration as StdDuration}; use chrono::{DateTime, Utc}; use dashmap::DashMap; use futures::{ StreamExt, future::{BoxFuture, FutureExt, Shared}, }; use json_patch::Patch; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::{broadcast, oneshot}; use tokio_stream::wrappers::BroadcastStream; use ts_rs::TS; use utils::approvals::{ApprovalOutcome, ApprovalRequest, ApprovalResponse}; use uuid::Uuid; #[derive(Debug)] struct PendingApproval { execution_process_id: Uuid, tool_name: String, is_question: bool, created_at: DateTime, timeout_at: DateTime, response_tx: oneshot::Sender, } pub(crate) type ApprovalWaiter = Shared>; #[derive(Debug)] pub struct ToolContext { pub tool_name: String, pub execution_process_id: Uuid, } /// Info about a currently pending approval, sent to the frontend via WebSocket. #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct ApprovalInfo { pub approval_id: String, pub tool_name: String, pub execution_process_id: Uuid, pub is_question: bool, pub created_at: DateTime, pub timeout_at: DateTime, } #[derive(Clone)] pub struct Approvals { pending: Arc>, completed: Arc>, patches_tx: broadcast::Sender, } #[derive(Debug, Error)] pub enum ApprovalError { #[error("approval request not found")] NotFound, #[error("approval request already completed")] AlreadyCompleted, #[error("no executor session found for session_id: {0}")] NoExecutorSession(String), #[error("invalid approval status for this tool type")] InvalidStatus, #[error(transparent)] Custom(#[from] anyhow::Error), } impl Default for Approvals { fn default() -> Self { Self::new() } } impl Approvals { pub fn new() -> Self { let (patches_tx, _) = broadcast::channel(64); Self { pending: Arc::new(DashMap::new()), completed: Arc::new(DashMap::new()), patches_tx, } } pub async fn create_with_waiter( &self, request: ApprovalRequest, is_question: bool, ) -> Result<(ApprovalRequest, ApprovalWaiter), ApprovalError> { let (tx, rx) = oneshot::channel(); let default_timeout = ApprovalOutcome::TimedOut; let waiter: ApprovalWaiter = rx .map(move |result| result.unwrap_or(default_timeout)) .boxed() .shared(); let req_id = request.id.clone(); let info = ApprovalInfo { approval_id: req_id.clone(), tool_name: request.tool_name.clone(), execution_process_id: request.execution_process_id, is_question, created_at: request.created_at, timeout_at: request.timeout_at, }; let pending_approval = PendingApproval { execution_process_id: request.execution_process_id, tool_name: request.tool_name.clone(), is_question, created_at: request.created_at, timeout_at: request.timeout_at, response_tx: tx, }; self.pending.insert(req_id.clone(), pending_approval); let _ = self .patches_tx .send(crate::services::events::patches::approvals_patch::created( &info, )); self.spawn_timeout_watcher(req_id.clone(), request.timeout_at, waiter.clone()); Ok((request, waiter)) } fn validate_approval_response( outcome: &ApprovalOutcome, is_question: bool, ) -> Result<(), ApprovalError> { match outcome { ApprovalOutcome::Approved | ApprovalOutcome::Denied { .. } if is_question => { Err(ApprovalError::InvalidStatus) } ApprovalOutcome::Answered { .. } if !is_question => Err(ApprovalError::InvalidStatus), _ => Ok(()), } } #[tracing::instrument(skip(self, id, req))] pub async fn respond( &self, id: &str, req: ApprovalResponse, ) -> Result<(ApprovalOutcome, ToolContext), ApprovalError> { if let Some((_, p)) = self.pending.remove(id) { if let Err(e) = Self::validate_approval_response(&req.status, p.is_question) { self.pending.insert(id.to_string(), p); return Err(e); } let outcome = req.status.clone(); self.completed.insert(id.to_string(), outcome.clone()); let _ = p.response_tx.send(outcome.clone()); let _ = self.patches_tx .send(crate::services::events::patches::approvals_patch::resolved( id, )); let tool_ctx = ToolContext { tool_name: p.tool_name, execution_process_id: p.execution_process_id, }; Ok((outcome, tool_ctx)) } else if self.completed.contains_key(id) { Err(ApprovalError::AlreadyCompleted) } else { Err(ApprovalError::NotFound) } } #[tracing::instrument(skip(self, id, timeout_at, waiter))] fn spawn_timeout_watcher( &self, id: String, timeout_at: chrono::DateTime, waiter: ApprovalWaiter, ) { let pending = self.pending.clone(); let completed = self.completed.clone(); let patches_tx = self.patches_tx.clone(); let timeout_outcome = ApprovalOutcome::TimedOut; let now = chrono::Utc::now(); let to_wait = (timeout_at - now) .to_std() .unwrap_or_else(|_| StdDuration::from_secs(0)); let deadline = tokio::time::Instant::now() + to_wait; tokio::spawn(async move { let outcome = tokio::select! { biased; resolved = waiter.clone() => resolved, _ = tokio::time::sleep_until(deadline) => timeout_outcome, }; let is_timeout = matches!(&outcome, ApprovalOutcome::TimedOut); completed.insert(id.clone(), outcome.clone()); if is_timeout && let Some((_, pending_approval)) = pending.remove(&id) { let _ = patches_tx.send( crate::services::events::patches::approvals_patch::resolved(&id), ); if pending_approval.response_tx.send(outcome).is_err() { tracing::debug!("approval '{}' timeout notification receiver dropped", id); } } }); } pub(crate) async fn cancel(&self, id: &str) { if let Some((_, _pending_approval)) = self.pending.remove(id) { let outcome = ApprovalOutcome::Denied { reason: Some("Cancelled".to_string()), }; self.completed.insert(id.to_string(), outcome); let _ = self.patches_tx .send(crate::services::events::patches::approvals_patch::resolved( id, )); tracing::debug!("Cancelled approval '{}'", id); } } pub fn patch_stream(&self) -> futures::stream::BoxStream<'static, Patch> { let approvals = self.clone(); let snapshot = crate::services::events::patches::approvals_patch::snapshot(&approvals.pending_infos()); let live = BroadcastStream::new(self.patches_tx.subscribe()).filter_map(move |result| { let approvals = approvals.clone(); async move { match result { Ok(patch) => Some(patch), Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(_)) => { Some(crate::services::events::patches::approvals_patch::snapshot( &approvals.pending_infos(), )) } } } }); futures::stream::iter([snapshot]).chain(live).boxed() } /// Check which execution processes have pending approvals. /// Returns a set of execution_process_ids that have at least one pending approval. pub fn get_pending_execution_process_ids( &self, execution_process_ids: &[Uuid], ) -> HashSet { let id_set: HashSet<_> = execution_process_ids.iter().collect(); self.pending .iter() .filter_map(|entry| { let ep_id = entry.value().execution_process_id; if id_set.contains(&ep_id) { Some(ep_id) } else { None } }) .collect() } fn pending_infos(&self) -> Vec { self.pending .iter() .map(|entry| { let p = entry.value(); ApprovalInfo { approval_id: entry.key().clone(), tool_name: p.tool_name.clone(), execution_process_id: p.execution_process_id, is_question: p.is_question, created_at: p.created_at, timeout_at: p.timeout_at, } }) .collect() } } ================================================ FILE: crates/services/src/services/auth.rs ================================================ use std::sync::Arc; use api_types::ProfileResponse; use tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard, RwLock}; use super::oauth_credentials::{Credentials, OAuthCredentials}; #[derive(Clone)] pub struct AuthContext { oauth: Arc, profile: Arc>>, refresh_lock: Arc>, } impl AuthContext { pub fn new( oauth: Arc, profile: Arc>>, ) -> Self { Self { oauth, profile, refresh_lock: Arc::new(TokioMutex::new(())), } } pub async fn get_credentials(&self) -> Option { self.oauth.get().await } pub async fn save_credentials(&self, creds: &Credentials) -> std::io::Result<()> { self.oauth.save(creds).await } pub async fn clear_credentials(&self) -> std::io::Result<()> { self.oauth.clear().await } pub async fn cached_profile(&self) -> Option { self.profile.read().await.clone() } pub async fn set_profile(&self, profile: ProfileResponse) { *self.profile.write().await = Some(profile) } pub async fn clear_profile(&self) { *self.profile.write().await = None } pub async fn refresh_guard(&self) -> OwnedMutexGuard<()> { self.refresh_lock.clone().lock_owned().await } } ================================================ FILE: crates/services/src/services/config/editor/mod.rs ================================================ use std::{path::Path, str::FromStr}; use executors::{command::CommandBuilder, executors::ExecutorError}; use serde::{Deserialize, Serialize}; use strum_macros::{EnumIter, EnumString}; use thiserror::Error; use ts_rs::TS; fn default_auto_install_extension() -> bool { true } #[derive(Debug, Clone, Serialize, Deserialize, TS, Error)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type", rename_all = "snake_case")] pub enum EditorOpenError { #[error("Editor executable '{executable}' not found in PATH")] ExecutableNotFound { executable: String, editor_type: EditorType, }, #[error("Editor command for {editor_type:?} is invalid: {details}")] InvalidCommand { details: String, editor_type: EditorType, }, #[error("Failed to launch '{executable}' for {editor_type:?}: {details}")] LaunchFailed { executable: String, details: String, editor_type: EditorType, }, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct EditorConfig { editor_type: EditorType, custom_command: Option, #[serde(default)] remote_ssh_host: Option, #[serde(default)] remote_ssh_user: Option, #[serde(default = "default_auto_install_extension")] auto_install_extension: bool, } #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString, EnumIter)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum EditorType { VsCode, VsCodeInsiders, Cursor, Windsurf, IntelliJ, Zed, Xcode, GoogleAntigravity, Custom, } impl Default for EditorConfig { fn default() -> Self { Self { editor_type: EditorType::VsCode, custom_command: None, remote_ssh_host: None, remote_ssh_user: None, auto_install_extension: true, } } } impl EditorConfig { /// Create a new EditorConfig. This is primarily used by version migrations. pub fn new( editor_type: EditorType, custom_command: Option, remote_ssh_host: Option, remote_ssh_user: Option, auto_install_extension: bool, ) -> Self { Self { editor_type, custom_command, remote_ssh_host, remote_ssh_user, auto_install_extension, } } pub fn get_command(&self) -> CommandBuilder { let base_command = match &self.editor_type { EditorType::VsCode => "code", EditorType::VsCodeInsiders => "code-insiders", EditorType::Cursor => "cursor", EditorType::Windsurf => "windsurf", EditorType::IntelliJ => "idea", EditorType::Zed => "zed", EditorType::Xcode => "xed", EditorType::GoogleAntigravity => "antigravity", EditorType::Custom => { // Custom editor - use user-provided command or fallback to VSCode self.custom_command.as_deref().unwrap_or("code") } }; CommandBuilder::new(base_command) } /// Resolve the editor command to an executable path and args. /// This is shared logic used by both check_availability() and spawn_local(). async fn resolve_command(&self) -> Result<(std::path::PathBuf, Vec), EditorOpenError> { let command_builder = self.get_command(); let command_parts = command_builder .build_initial() .map_err(|e| EditorOpenError::InvalidCommand { details: e.to_string(), editor_type: self.editor_type.clone(), })?; let (executable, args) = command_parts.into_resolved().await.map_err(|e| match e { ExecutorError::ExecutableNotFound { program } => EditorOpenError::ExecutableNotFound { executable: program, editor_type: self.editor_type.clone(), }, _ => EditorOpenError::InvalidCommand { details: e.to_string(), editor_type: self.editor_type.clone(), }, })?; Ok((executable, args)) } /// Check if the editor is available on the system. /// Uses the same command resolution logic as spawn_local(). pub async fn check_availability(&self) -> bool { self.resolve_command().await.is_ok() } fn should_auto_install_extension(&self) -> bool { self.auto_install_extension && matches!( self.editor_type, EditorType::VsCode | EditorType::VsCodeInsiders | EditorType::Cursor ) } async fn try_install_extension(&self) { let Ok((executable, args)) = self.resolve_command().await else { return; }; use utils::command_ext::NoWindowExt; let mut cmd = std::process::Command::new(&executable); cmd.args(&args) .arg("--install-extension") .arg("bloop.vibe-kanban"); let _ = cmd.no_window().spawn(); } pub async fn open_file(&self, path: &Path) -> Result, EditorOpenError> { if let Some(url) = self.remote_url(path) { return Ok(Some(url)); } if self.should_auto_install_extension() { self.try_install_extension().await; } self.spawn_local(path).await?; Ok(None) } fn remote_url(&self, path: &Path) -> Option { let remote_host = self.remote_ssh_host.as_ref()?; let user_part = self .remote_ssh_user .as_ref() .map(|u| format!("{u}@")) .unwrap_or_default(); let path_str = path.to_string_lossy(); let scheme = match self.editor_type { EditorType::VsCode => "vscode", EditorType::VsCodeInsiders => "vscode-insiders", EditorType::Cursor => "cursor", EditorType::Windsurf => "windsurf", EditorType::GoogleAntigravity => "antigravity", EditorType::Zed => { return Some(format!("zed://ssh/{user_part}{remote_host}{path_str}")); } _ => return None, }; // files must contain a line and column number let line_col = if path.is_file() { ":1:1" } else { "" }; Some(format!( "{scheme}://vscode-remote/ssh-remote+{user_part}{remote_host}{path_str}{line_col}?windowId=_blank" )) } pub async fn spawn_local(&self, path: &Path) -> Result<(), EditorOpenError> { let (executable, args) = self.resolve_command().await?; use utils::command_ext::NoWindowExt; let mut cmd = std::process::Command::new(&executable); cmd.args(&args).arg(path); cmd.no_window() .spawn() .map_err(|e| EditorOpenError::LaunchFailed { executable: executable.to_string_lossy().into_owned(), details: e.to_string(), editor_type: self.editor_type.clone(), })?; Ok(()) } pub fn with_override(&self, editor_type_str: Option<&str>) -> Self { if let Some(editor_type_str) = editor_type_str { let editor_type = EditorType::from_str(editor_type_str).unwrap_or(self.editor_type.clone()); EditorConfig { editor_type, custom_command: self.custom_command.clone(), remote_ssh_host: self.remote_ssh_host.clone(), remote_ssh_user: self.remote_ssh_user.clone(), auto_install_extension: self.auto_install_extension, } } else { self.clone() } } } ================================================ FILE: crates/services/src/services/config/mod.rs ================================================ use std::path::PathBuf; use thiserror::Error; pub mod editor; mod versions; pub use editor::EditorOpenError; pub const DEFAULT_PR_DESCRIPTION_PROMPT: &str = r#"Update the PR that was just created with a better title and description. The PR number is #{pr_number} and the URL is {pr_url}. Analyze the changes in this branch and write: 1. A concise, descriptive title that summarizes the changes, postfixed with "(Vibe Kanban)" 2. A detailed description that explains: - What changes were made - Why they were made (based on the task context) - Any important implementation details - At the end, include a note: "This PR was written using [Vibe Kanban](https://vibekanban.com)" Use the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps)."#; pub const DEFAULT_COMMIT_REMINDER_PROMPT: &str = "There are uncommitted changes. Please stage and commit them now with a descriptive commit message."; #[derive(Debug, Error)] pub enum ConfigError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Json(#[from] serde_json::Error), #[error("Validation error: {0}")] ValidationError(String), } pub type Config = versions::v8::Config; pub type NotificationConfig = versions::v8::NotificationConfig; pub type EditorConfig = versions::v8::EditorConfig; pub type ThemeMode = versions::v8::ThemeMode; pub type SoundFile = versions::v8::SoundFile; pub type EditorType = versions::v8::EditorType; pub type GitHubConfig = versions::v8::GitHubConfig; pub type UiLanguage = versions::v8::UiLanguage; pub type ShowcaseState = versions::v8::ShowcaseState; pub type SendMessageShortcut = versions::v8::SendMessageShortcut; /// Will always return config, trying old schemas or eventually returning default pub async fn load_config_from_file(config_path: &PathBuf) -> Config { match std::fs::read_to_string(config_path) { Ok(raw_config) => Config::from(raw_config), Err(_) => { tracing::info!("No config file found, creating one"); Config::default() } } } /// Saves the config to the given path pub async fn save_config_to_file( config: &Config, config_path: &PathBuf, ) -> Result<(), ConfigError> { let raw_config = serde_json::to_string_pretty(config)?; std::fs::write(config_path, raw_config)?; Ok(()) } ================================================ FILE: crates/services/src/services/config/versions/mod.rs ================================================ pub(super) mod v1; pub(super) mod v2; pub(super) mod v3; pub(super) mod v4; pub(super) mod v5; pub(super) mod v6; pub(super) mod v7; pub(super) mod v8; ================================================ FILE: crates/services/src/services/config/versions/v1.rs ================================================ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct Config { pub(super) theme: ThemeMode, pub(super) executor: ExecutorConfig, pub(super) disclaimer_acknowledged: bool, pub(super) onboarding_acknowledged: bool, pub(super) github_login_acknowledged: bool, pub(super) telemetry_acknowledged: bool, pub(super) sound_alerts: bool, pub(super) sound_file: SoundFile, pub(super) push_notifications: bool, pub(super) editor: EditorConfig, pub(super) github: GitHubConfig, pub(super) analytics_enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub(super) enum ExecutorConfig { Echo, Claude, ClaudePlan, Amp, Gemini, #[serde(alias = "setup_script")] SetupScript { script: String, }, ClaudeCodeRouter, #[serde(alias = "charmopencode")] CharmOpencode, #[serde(alias = "opencode")] SstOpencode, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(super) enum ThemeMode { Light, Dark, System, Purple, Green, Blue, Orange, Red, } #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct EditorConfig { pub editor_type: EditorType, pub custom_command: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct GitHubConfig { pub pat: Option, pub token: Option, pub username: Option, pub primary_email: Option, pub default_pr_base: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(super) enum EditorType { VsCode, Cursor, Windsurf, IntelliJ, Zed, Custom, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(super) enum SoundFile { AbstractSound1, AbstractSound2, AbstractSound3, AbstractSound4, CowMooing, PhoneVibration, Rooster, } ================================================ FILE: crates/services/src/services/config/versions/v2.rs ================================================ use std::path::PathBuf; use anyhow::Error; use serde::{Deserialize, Serialize}; use strum_macros::EnumString; use ts_rs::TS; use utils::{assets::SoundAssets, cache_dir}; // Re-export editor config from the dedicated editor module pub use crate::services::config::editor::{EditorConfig, EditorType}; use crate::services::config::versions::v1; // Keep the From conversions here since v1 types are only accessible within versions module impl From for EditorConfig { fn from(old: v1::EditorConfig) -> Self { EditorConfig::new( EditorType::from(old.editor_type), old.custom_command, None, None, true, ) } } impl From for EditorType { fn from(old: v1::EditorType) -> Self { match old { v1::EditorType::VsCode => EditorType::VsCode, v1::EditorType::Cursor => EditorType::Cursor, v1::EditorType::Windsurf => EditorType::Windsurf, v1::EditorType::IntelliJ => EditorType::IntelliJ, v1::EditorType::Zed => EditorType::Zed, v1::EditorType::Custom => EditorType::Custom, } } } #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub profile: String, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; let old_config_clone = old_config.clone(); let mut onboarding_acknowledged = old_config.onboarding_acknowledged; // Map old executors to new profiles let profile: &str = match old_config.executor { v1::ExecutorConfig::Claude => "claude-code", v1::ExecutorConfig::ClaudeCodeRouter => "claude-code", v1::ExecutorConfig::ClaudePlan => "claude-code-plan", v1::ExecutorConfig::Amp => "amp", v1::ExecutorConfig::Gemini => "gemini", v1::ExecutorConfig::SstOpencode => "opencode", _ => { onboarding_acknowledged = false; // Reset the user's onboarding if executor is not supported "claude-code" } }; Ok(Self { config_version: "v2".to_string(), theme: ThemeMode::from(old_config.theme), // Now SCREAMING_SNAKE_CASE profile: profile.to_string(), disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: NotificationConfig::from(old_config_clone), editor: EditorConfig::from(old_config.editor), github: GitHubConfig::from(old_config.github), analytics_enabled: None, workspace_dir: None, }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str(&raw_config) { config } else if let Ok(config) = Self::from_previous_version(&raw_config) { tracing::info!("Config upgraded from previous version"); config } else { tracing::warn!("Config reset to default"); Self::default() } } } impl Default for Config { fn default() -> Self { Self { config_version: "v2".to_string(), theme: ThemeMode::System, profile: String::from("claude-code"), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct GitHubConfig { pub pat: Option, pub oauth_token: Option, pub username: Option, pub primary_email: Option, pub default_pr_base: Option, } impl From for GitHubConfig { fn from(old: v1::GitHubConfig) -> Self { Self { pat: old.pat, oauth_token: old.token, // Map to new field name username: old.username, primary_email: old.primary_email, default_pr_base: old.default_pr_base, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct NotificationConfig { pub sound_enabled: bool, pub push_enabled: bool, pub sound_file: SoundFile, } impl From for NotificationConfig { fn from(old: v1::Config) -> Self { Self { sound_enabled: old.sound_alerts, push_enabled: old.push_notifications, sound_file: SoundFile::from(old.sound_file), // Now SCREAMING_SNAKE_CASE } } } impl Default for NotificationConfig { fn default() -> Self { Self { sound_enabled: true, push_enabled: true, sound_file: SoundFile::CowMooing, } } } impl Default for GitHubConfig { fn default() -> Self { Self { pat: None, oauth_token: None, username: None, primary_email: None, default_pr_base: Some("main".to_string()), } } } impl GitHubConfig { pub fn token(&self) -> Option { self.pat .as_deref() .or(self.oauth_token.as_deref()) .map(|s| s.to_string()) } } #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum SoundFile { AbstractSound1, AbstractSound2, AbstractSound3, AbstractSound4, CowMooing, Fahhhhh, PhoneVibration, Rooster, } impl SoundFile { pub fn to_filename(&self) -> &'static str { match self { SoundFile::AbstractSound1 => "abstract-sound1.wav", SoundFile::AbstractSound2 => "abstract-sound2.wav", SoundFile::AbstractSound3 => "abstract-sound3.wav", SoundFile::AbstractSound4 => "abstract-sound4.wav", SoundFile::CowMooing => "cow-mooing.wav", SoundFile::Fahhhhh => "fahhhhh.wav", SoundFile::PhoneVibration => "phone-vibration.wav", SoundFile::Rooster => "rooster.wav", } } // load the sound file from the embedded assets or cache pub async fn serve(&self) -> Result { match SoundAssets::get(self.to_filename()) { Some(content) => Ok(content), None => { tracing::error!("Sound file not found: {}", self.to_filename()); Err(anyhow::anyhow!( "Sound file not found: {}", self.to_filename() )) } } } /// Get or create a cached sound file with the embedded sound data pub async fn get_path(&self) -> Result> { use std::io::Write; let filename = self.to_filename(); let cache_dir = cache_dir(); let cached_path = cache_dir.join(format!("sound-{filename}")); // Check if cached file already exists and is valid if cached_path.exists() { // Verify file has content (basic validation) if let Ok(metadata) = std::fs::metadata(&cached_path) && metadata.len() > 0 { return Ok(cached_path); } } // File doesn't exist or is invalid, create it let sound_data = SoundAssets::get(filename) .ok_or_else(|| format!("Embedded sound file not found: {filename}"))? .data; // Ensure cache directory exists std::fs::create_dir_all(&cache_dir) .map_err(|e| format!("Failed to create cache directory: {e}"))?; let mut file = std::fs::File::create(&cached_path) .map_err(|e| format!("Failed to create cached sound file: {e}"))?; file.write_all(&sound_data) .map_err(|e| format!("Failed to write sound data to cached file: {e}"))?; drop(file); // Ensure file is closed Ok(cached_path) } } impl From for SoundFile { fn from(old: v1::SoundFile) -> Self { match old { v1::SoundFile::AbstractSound1 => SoundFile::AbstractSound1, v1::SoundFile::AbstractSound2 => SoundFile::AbstractSound2, v1::SoundFile::AbstractSound3 => SoundFile::AbstractSound3, v1::SoundFile::AbstractSound4 => SoundFile::AbstractSound4, v1::SoundFile::CowMooing => SoundFile::CowMooing, v1::SoundFile::PhoneVibration => SoundFile::PhoneVibration, v1::SoundFile::Rooster => SoundFile::Rooster, } } } #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum ThemeMode { Light, Dark, System, Purple, Green, Blue, Orange, Red, } impl From for ThemeMode { fn from(old: v1::ThemeMode) -> Self { match old { v1::ThemeMode::Light => ThemeMode::Light, v1::ThemeMode::Dark => ThemeMode::Dark, v1::ThemeMode::System => ThemeMode::System, v1::ThemeMode::Purple => ThemeMode::Purple, v1::ThemeMode::Green => ThemeMode::Green, v1::ThemeMode::Blue => ThemeMode::Blue, v1::ThemeMode::Orange => ThemeMode::Orange, v1::ThemeMode::Red => ThemeMode::Red, } } } ================================================ FILE: crates/services/src/services/config/versions/v3.rs ================================================ use anyhow::Error; use serde::{Deserialize, Serialize}; use ts_rs::TS; pub use v2::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode}; use crate::services::config::versions::v2; #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub profile: String, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; Ok(Self { config_version: "v3".to_string(), theme: old_config.theme, profile: old_config.profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, telemetry_acknowledged: false, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled: old_config.analytics_enabled, workspace_dir: old_config.workspace_dir, }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v3" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v3"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v3".to_string(), theme: ThemeMode::System, profile: String::from("claude-code"), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, } } } ================================================ FILE: crates/services/src/services/config/versions/v4.rs ================================================ use anyhow::Error; use serde::{Deserialize, Serialize}; use ts_rs::TS; pub use v3::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode}; use crate::services::config::versions::v3; // DEPRECATED #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct ProfileVariantLabel { pub profile: String, pub variant: Option, } impl ProfileVariantLabel { pub fn default(profile: String) -> Self { Self { profile, variant: None, } } pub fn with_variant(profile: String, mode: String) -> Self { Self { profile, variant: Some(mode), } } } #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub profile: ProfileVariantLabel, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; let mut onboarding_acknowledged = old_config.onboarding_acknowledged; let profile = match old_config.profile.as_str() { "claude-code" => ProfileVariantLabel::default("claude-code".to_string()), "claude-code-plan" => { ProfileVariantLabel::with_variant("claude-code".to_string(), "plan".to_string()) } "claude-code-router" => { ProfileVariantLabel::with_variant("claude-code".to_string(), "router".to_string()) } "amp" => ProfileVariantLabel::default("amp".to_string()), "gemini" => ProfileVariantLabel::default("gemini".to_string()), "codex" => ProfileVariantLabel::default("codex".to_string()), "opencode" => ProfileVariantLabel::default("opencode".to_string()), "qwen-code" => ProfileVariantLabel::default("qwen-code".to_string()), _ => { onboarding_acknowledged = false; // Reset the user's onboarding if executor is not supported ProfileVariantLabel::default("claude-code".to_string()) } }; Ok(Self { config_version: "v4".to_string(), theme: old_config.theme, profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled: old_config.analytics_enabled, workspace_dir: old_config.workspace_dir, }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v4" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v3"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v4".to_string(), theme: ThemeMode::System, profile: ProfileVariantLabel::default("claude-code".to_string()), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, } } } ================================================ FILE: crates/services/src/services/config/versions/v5.rs ================================================ use anyhow::Error; use serde::{Deserialize, Serialize}; use ts_rs::TS; pub use v4::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode}; use crate::services::config::versions::v4::{self, ProfileVariantLabel}; #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub profile: ProfileVariantLabel, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, pub last_app_version: Option, pub show_release_notes: bool, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; Ok(Self { config_version: "v5".to_string(), theme: old_config.theme, profile: old_config.profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled: old_config.analytics_enabled, workspace_dir: old_config.workspace_dir, last_app_version: None, show_release_notes: false, }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v5" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v5"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v5".to_string(), theme: ThemeMode::System, profile: ProfileVariantLabel::default("claude-code".to_string()), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, last_app_version: None, show_release_notes: false, } } } ================================================ FILE: crates/services/src/services/config/versions/v6.rs ================================================ use std::str::FromStr; use anyhow::Error; use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use utils; pub use v5::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, ThemeMode}; use crate::services::config::versions::v5; #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS, Default)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum UiLanguage { #[default] Browser, // Detect from browser En, // Force English Fr, // Force French Ja, // Force Japanese Es, // Force Spanish Ko, // Force Korean ZhHans, // Force Simplified Chinese ZhHant, // Force Traditional Chinese } #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub executor_profile: ExecutorProfileId, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, pub last_app_version: Option, pub show_release_notes: bool, #[serde(default)] pub language: UiLanguage, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; // Backup custom profiles.json if it exists (v6 migration may break compatibility) let profiles_path = utils::assets::profiles_path(); if profiles_path.exists() { let backup_name = format!( "profiles_v5_backup_{}.json", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() ); let backup_path = profiles_path.parent().unwrap().join(backup_name); if let Err(e) = std::fs::rename(&profiles_path, &backup_path) { tracing::warn!("Failed to backup profiles.json: {}", e); } else { tracing::info!("Custom profiles.json backed up to {:?}", backup_path); tracing::info!("Please review your custom profiles after migration to v6"); } } // Validate and convert ProfileVariantLabel let old_coding_agent = old_config.profile.profile.to_uppercase(); let base_coding_agent = BaseCodingAgent::from_str(&old_coding_agent).unwrap_or(BaseCodingAgent::ClaudeCode); let executor_profile = ExecutorProfileId::new(base_coding_agent); Ok(Self { config_version: "v6".to_string(), theme: old_config.theme, executor_profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled: old_config.analytics_enabled, workspace_dir: old_config.workspace_dir, last_app_version: old_config.last_app_version, show_release_notes: old_config.show_release_notes, language: UiLanguage::default(), }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v6" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v6"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v6".to_string(), theme: ThemeMode::System, executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, last_app_version: None, show_release_notes: false, language: UiLanguage::default(), } } } ================================================ FILE: crates/services/src/services/config/versions/v7.rs ================================================ use anyhow::Error; use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; use serde::{Deserialize, Serialize}; use strum_macros::EnumString; use ts_rs::TS; pub use v6::{EditorConfig, EditorType, GitHubConfig, NotificationConfig, SoundFile, UiLanguage}; use crate::services::config::versions::v6; fn default_git_branch_prefix() -> String { "vk".to_string() } #[derive(Clone, Debug, Serialize, Deserialize, TS, Default)] pub struct ShowcaseState { #[serde(default)] pub seen_features: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum ThemeMode { Light, Dark, System, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub executor_profile: ExecutorProfileId, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, #[serde(default)] pub login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: Option, pub workspace_dir: Option, pub last_app_version: Option, pub show_release_notes: bool, #[serde(default)] pub language: UiLanguage, #[serde(default = "default_git_branch_prefix")] pub git_branch_prefix: String, #[serde(default)] pub showcases: ShowcaseState, } impl Config { pub fn from_previous_version(raw_config: &str) -> Result { let old_config = match serde_json::from_str::(raw_config) { Ok(cfg) => cfg, Err(e) => { tracing::error!("❌ Failed to parse config: {}", e); tracing::error!(" at line {}, column {}", e.line(), e.column()); return Err(e.into()); } }; // Map old theme modes to new simplified theme modes let theme = match old_config.theme { v6::ThemeMode::Light => ThemeMode::Light, v6::ThemeMode::Dark => ThemeMode::Dark, v6::ThemeMode::System => ThemeMode::System, // Map all color themes to System (respects user's OS preference) v6::ThemeMode::Purple | v6::ThemeMode::Green | v6::ThemeMode::Blue | v6::ThemeMode::Orange | v6::ThemeMode::Red => { tracing::info!( "Migrating color theme {:?} to System theme", old_config.theme ); ThemeMode::System } }; Ok(Self { config_version: "v7".to_string(), theme, executor_profile: old_config.executor_profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, login_acknowledged: false, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled: old_config.analytics_enabled, workspace_dir: old_config.workspace_dir, last_app_version: old_config.last_app_version, show_release_notes: old_config.show_release_notes, language: old_config.language, git_branch_prefix: default_git_branch_prefix(), showcases: ShowcaseState::default(), }) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v7" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v7"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v7".to_string(), theme: ThemeMode::System, executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode), disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: None, workspace_dir: None, last_app_version: None, show_release_notes: false, language: UiLanguage::default(), git_branch_prefix: default_git_branch_prefix(), showcases: ShowcaseState::default(), } } } ================================================ FILE: crates/services/src/services/config/versions/v8.rs ================================================ use anyhow::Error; use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; use serde::{Deserialize, Serialize}; use ts_rs::TS; pub use v7::{ EditorConfig, EditorType, GitHubConfig, NotificationConfig, ShowcaseState, SoundFile, ThemeMode, UiLanguage, }; use crate::services::config::versions::v7; fn default_git_branch_prefix() -> String { "vk".to_string() } fn default_pr_auto_description_enabled() -> bool { true } fn default_commit_reminder_enabled() -> bool { true } fn default_relay_enabled() -> bool { true } #[derive(Clone, Debug, Default, Serialize, Deserialize, TS, PartialEq, Eq)] pub enum SendMessageShortcut { #[default] ModifierEnter, Enter, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, pub theme: ThemeMode, pub executor_profile: ExecutorProfileId, pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, #[serde(default)] pub remote_onboarding_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, pub github: GitHubConfig, pub analytics_enabled: bool, pub workspace_dir: Option, pub last_app_version: Option, pub show_release_notes: bool, #[serde(default)] pub language: UiLanguage, #[serde(default = "default_git_branch_prefix")] pub git_branch_prefix: String, #[serde(default)] pub showcases: ShowcaseState, #[serde(default = "default_pr_auto_description_enabled")] pub pr_auto_description_enabled: bool, #[serde(default)] pub pr_auto_description_prompt: Option, #[serde(default = "default_commit_reminder_enabled")] pub commit_reminder_enabled: bool, #[serde(default)] pub commit_reminder_prompt: Option, #[serde(default)] pub send_message_shortcut: SendMessageShortcut, #[serde(default = "default_relay_enabled")] pub relay_enabled: bool, #[serde(default)] pub relay_host_name: Option, } impl Config { fn from_v7_config(old_config: v7::Config) -> Self { // Convert Option to bool: None or Some(true) become true, Some(false) stays false let analytics_enabled = old_config.analytics_enabled.unwrap_or(true); Self { config_version: "v8".to_string(), theme: old_config.theme, executor_profile: old_config.executor_profile, disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, remote_onboarding_acknowledged: false, notifications: old_config.notifications, editor: old_config.editor, github: old_config.github, analytics_enabled, workspace_dir: old_config.workspace_dir, last_app_version: old_config.last_app_version, show_release_notes: old_config.show_release_notes, language: old_config.language, git_branch_prefix: old_config.git_branch_prefix, showcases: old_config.showcases, pr_auto_description_enabled: true, pr_auto_description_prompt: None, commit_reminder_enabled: true, commit_reminder_prompt: None, send_message_shortcut: SendMessageShortcut::default(), relay_enabled: true, relay_host_name: None, } } pub fn from_previous_version(raw_config: &str) -> Result { let old_config = v7::Config::from(raw_config.to_string()); Ok(Self::from_v7_config(old_config)) } } impl From for Config { fn from(raw_config: String) -> Self { if let Ok(config) = serde_json::from_str::(&raw_config) && config.config_version == "v8" { return config; } match Self::from_previous_version(&raw_config) { Ok(config) => { tracing::info!("Config upgraded to v8"); config } Err(e) => { tracing::warn!("Config migration failed: {}, using default", e); Self::default() } } } } impl Default for Config { fn default() -> Self { Self { config_version: "v8".to_string(), theme: ThemeMode::System, executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode), disclaimer_acknowledged: false, onboarding_acknowledged: false, remote_onboarding_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), github: GitHubConfig::default(), analytics_enabled: true, workspace_dir: None, last_app_version: None, show_release_notes: false, language: UiLanguage::default(), git_branch_prefix: default_git_branch_prefix(), showcases: ShowcaseState::default(), pr_auto_description_enabled: true, pr_auto_description_prompt: None, commit_reminder_enabled: true, commit_reminder_prompt: None, send_message_shortcut: SendMessageShortcut::default(), relay_enabled: true, relay_host_name: None, } } } ================================================ FILE: crates/services/src/services/container.rs ================================================ use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, }; use anyhow::{Error as AnyhowError, anyhow}; use async_trait::async_trait; use db::{ DBService, models::{ coding_agent_turn::{CodingAgentTurn, CreateCodingAgentTurn}, execution_process::{ CreateExecutionProcess, ExecutionContext, ExecutionProcess, ExecutionProcessError, ExecutionProcessRunReason, ExecutionProcessStatus, }, execution_process_repo_state::{ CreateExecutionProcessRepoState, ExecutionProcessRepoState, }, repo::Repo, session::{CreateSession, Session, SessionError}, workspace::{Workspace, WorkspaceError}, workspace_repo::WorkspaceRepo, }, }; #[cfg(feature = "qa-mode")] use executors::executors::qa_mock::QaMockExecutor; #[cfg(not(feature = "qa-mode"))] use executors::profile::ExecutorConfigs; use executors::{ actions::{ ExecutorAction, ExecutorActionType, coding_agent_initial::CodingAgentInitialRequest, script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, }, executors::{ExecutorError, StandardCodingAgentExecutor}, logs::{ NormalizedEntry, NormalizedEntryError, NormalizedEntryType, utils::{ ConversationPatch, patch::{fix_patch_ops, is_add_or_replace, patch_entry_path}, }, }, profile::{ExecutorConfig, ExecutorProfileId}, }; use futures::{StreamExt, future, stream::BoxStream}; use git::{GitService, GitServiceError}; use json_patch::Patch; use sqlx::Error as SqlxError; use thiserror::Error; use tokio::{sync::RwLock, task::JoinHandle}; use utils::{ log_msg::LogMsg, msg_store::MsgStore, text::{git_branch_id, short_uuid}, }; use uuid::Uuid; use worktree_manager::WorktreeError; use crate::services::{execution_process, notification::NotificationService}; pub type ContainerRef = String; #[derive(Debug, Error)] pub enum ContainerError { #[error(transparent)] GitServiceError(#[from] GitServiceError), #[error(transparent)] Sqlx(#[from] SqlxError), #[error(transparent)] ExecutorError(#[from] ExecutorError), #[error(transparent)] Worktree(#[from] WorktreeError), #[error(transparent)] Workspace(#[from] WorkspaceError), #[error(transparent)] Session(#[from] SessionError), #[error(transparent)] ExecutionProcess(#[from] ExecutionProcessError), #[error("Io error: {0}")] Io(#[from] std::io::Error), #[error("Failed to kill process: {0}")] KillFailed(std::io::Error), #[error(transparent)] Other(#[from] AnyhowError), // Catches any unclassified errors } #[async_trait] pub trait ContainerService { fn msg_stores(&self) -> &Arc>>>; fn db(&self) -> &DBService; fn git(&self) -> &GitService; fn notification_service(&self) -> &NotificationService; async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError>; fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf; async fn discover_executor_options( &self, executor_profile_id: ExecutorProfileId, session_id: Option, workspace_id: Option, repo_id: Option, ) -> Result>, ContainerError> { let (workdir, repo_path) = if let Some(session_id) = session_id { let session = Session::find_by_id(&self.db().pool, session_id) .await? .ok_or(SqlxError::RowNotFound)?; if let Some(workspace_id) = workspace_id && session.workspace_id != workspace_id { return Err(ContainerError::Other(anyhow!( "Session does not belong to workspace" ))); } let workspace = Workspace::find_by_id(&self.db().pool, session.workspace_id) .await? .ok_or(SqlxError::RowNotFound)?; let container_ref = match workspace.container_ref.as_deref() { Some(container_ref) if !container_ref.is_empty() => container_ref, _ => &self.ensure_container_exists(&workspace).await?, }; if container_ref.is_empty() { return Err(ContainerError::Other(anyhow!("Workspace path is empty"))); } let workspace_path = PathBuf::from(container_ref); let workdir = match session.agent_working_dir.as_deref() { Some(dir) if !dir.is_empty() => Some(workspace_path.join(dir)), _ => Some(workspace_path), }; let repos = WorkspaceRepo::find_repos_for_workspace(&self.db().pool, session.workspace_id) .await .unwrap_or_default(); let repo_path = if repos.len() == 1 { Some(repos[0].path.clone()) } else { None }; (workdir, repo_path) } else if workspace_id.is_some() { return Err(ContainerError::Other(anyhow!( "session_id is required when workspace_id is provided" ))); } else if let Some(repo_id) = repo_id { let repo = Repo::find_by_id(&self.db().pool, repo_id) .await .ok() .flatten() .map(|repo| repo.path); (None, repo) } else { (None, None) }; #[cfg(feature = "qa-mode")] { let _ = executor_profile_id; let _ = workdir; let _ = repo_path; return Ok(None); } #[cfg(not(feature = "qa-mode"))] { let executor = ExecutorConfigs::get_cached().get_coding_agent_or_default(&executor_profile_id); // Spawn background task to refresh global cache for this executor let base_agent = executors::executors::BaseCodingAgent::from(&executor); executors::executors::utils::spawn_global_cache_refresh_for_agent(base_agent); let stream = executor .discover_options(workdir.as_deref(), repo_path.as_deref()) .await?; Ok(Some(stream)) } } async fn store_db_stream_handle(&self, id: Uuid, handle: JoinHandle<()>); async fn take_db_stream_handle(&self, id: &Uuid) -> Option>; async fn create(&self, workspace: &Workspace) -> Result; async fn kill_all_running_processes(&self) -> Result<(), ContainerError>; async fn delete(&self, workspace: &Workspace) -> Result<(), ContainerError>; /// A context is finalized when /// - Always when the execution process has failed or been killed /// - Never when the run reason is DevServer /// - Never when a setup script has no next_action (parallel mode) /// - The next action is None (no follow-up actions) fn should_finalize(&self, ctx: &ExecutionContext) -> bool { // Never finalize DevServer processes if matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::DevServer ) { return false; } // Never finalize setup scripts without a next_action (parallel mode). // In sequential mode, setup scripts have next_action pointing to coding agent, // so they won't finalize anyway (handled by next_action.is_none() check below). let action = ctx.execution_process.executor_action().unwrap(); if matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::SetupScript ) && action.next_action.is_none() { return false; } // Always finalize failed or killed executions, regardless of next action if matches!( ctx.execution_process.status, ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed ) { return true; } // Otherwise, finalize only if no next action action.next_action.is_none() } /// Finalize workspace execution by sending notifications async fn finalize_task(&self, ctx: &ExecutionContext) { // Skip notification if process was intentionally killed by user if matches!(ctx.execution_process.status, ExecutionProcessStatus::Killed) { return; } let workspace_name = ctx .workspace .name .as_deref() .unwrap_or(&ctx.workspace.branch); let title = format!("Workspace Complete: {}", workspace_name); let message = match ctx.execution_process.status { ExecutionProcessStatus::Completed => format!( "✅ '{}' completed successfully\nBranch: {:?}\nExecutor: {:?}", workspace_name, ctx.workspace.branch, ctx.session.executor ), ExecutionProcessStatus::Failed => format!( "❌ '{}' execution failed\nBranch: {:?}\nExecutor: {:?}", workspace_name, ctx.workspace.branch, ctx.session.executor ), _ => { tracing::warn!( "Tried to notify workspace completion for {} but process is still running!", ctx.workspace.id ); return; } }; self.notification_service() .notify(&title, &message, Some(ctx.workspace.id)) .await; } /// Cleanup executions marked as running in the db, call at startup async fn cleanup_orphan_executions(&self) -> Result<(), ContainerError> { let running_processes = ExecutionProcess::find_running(&self.db().pool).await?; for process in running_processes { tracing::info!( "Found orphaned execution process {} for session {}", process.id, process.session_id ); // Update the execution process status first if let Err(e) = ExecutionProcess::update_completion( &self.db().pool, process.id, ExecutionProcessStatus::Failed, None, // No exit code for orphaned processes ) .await { tracing::error!( "Failed to update orphaned execution process {} status: {}", process.id, e ); continue; } // Capture after-head commit OID per repository if let Ok(ctx) = ExecutionProcess::load_context(&self.db().pool, process.id).await && let Some(ref container_ref) = ctx.workspace.container_ref { let workspace_root = PathBuf::from(container_ref); for repo in &ctx.repos { let repo_path = workspace_root.join(&repo.name); if let Ok(head) = self.git().get_head_info(&repo_path) && let Err(err) = ExecutionProcessRepoState::update_after_head_commit( &self.db().pool, process.id, repo.id, &head.oid, ) .await { tracing::warn!( "Failed to update after_head_commit for repo {} on process {}: {}", repo.id, process.id, err ); } } } // Process marked as failed tracing::info!("Marked orphaned execution process {} as failed", process.id); } Ok(()) } /// Backfill before_head_commit for legacy execution processes. /// Rules: /// - If a process has after_head_commit and missing before_head_commit, /// then set before_head_commit to the previous process's after_head_commit. /// - If there is no previous process, set before_head_commit to the base branch commit. async fn backfill_before_head_commits(&self) -> Result<(), ContainerError> { let pool = &self.db().pool; let rows = ExecutionProcess::list_missing_before_context(pool).await?; for row in rows { // Skip if no after commit at all (shouldn't happen due to WHERE) // Prefer previous process after-commit if present let mut before = row.prev_after_head_commit.clone(); // Fallback to base branch commit OID if before.is_none() { let repo_path = std::path::Path::new(row.repo_path.as_deref().unwrap_or_default()); match self .git() .get_branch_oid(repo_path, row.target_branch.as_str()) { Ok(oid) => before = Some(oid), Err(e) => { tracing::warn!( "Backfill: Failed to resolve base branch OID for workspace {} (branch {}): {}", row.workspace_id, row.target_branch, e ); } } } if let Some(before_oid) = before && let Err(e) = ExecutionProcessRepoState::update_before_head_commit( pool, row.id, row.repo_id, &before_oid, ) .await { tracing::warn!( "Backfill: Failed to update before_head_commit for process {}: {}", row.id, e ); } } Ok(()) } /// Backfill repo names that were migrated with a sentinel placeholder. /// Also backfills dev_script_working_dir and agent_working_dir for single-repo projects. async fn backfill_repo_names(&self) -> Result<(), ContainerError> { let pool = &self.db().pool; let repos = Repo::list_needing_name_fix(pool).await?; if repos.is_empty() { return Ok(()); } tracing::info!("Backfilling {} repo names", repos.len()); for repo in repos { let name = repo .path .file_name() .and_then(|n| n.to_str()) .unwrap_or(&repo.id.to_string()) .to_string(); Repo::update_name(pool, repo.id, &name, &name).await?; } Ok(()) } fn cleanup_actions_for_repos(&self, repos: &[Repo]) -> Option { let repos_with_cleanup: Vec<_> = repos .iter() .filter(|r| r.cleanup_script.is_some()) .collect(); if repos_with_cleanup.is_empty() { return None; } let mut iter = repos_with_cleanup.iter(); let first = iter.next()?; let mut root_action = ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: first.cleanup_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::CleanupScript, working_dir: Some(first.name.clone()), }), None, ); for repo in iter { root_action = root_action.append_action(ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: repo.cleanup_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::CleanupScript, working_dir: Some(repo.name.clone()), }), None, )); } Some(root_action) } fn archive_actions_for_repos(&self, repos: &[Repo]) -> Option { let repos_with_archive: Vec<_> = repos .iter() .filter(|r| r.archive_script.is_some()) .collect(); if repos_with_archive.is_empty() { return None; } let mut iter = repos_with_archive.iter(); let first = iter.next()?; let mut root_action = ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: first.archive_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::ArchiveScript, working_dir: Some(first.name.clone()), }), None, ); for repo in iter { root_action = root_action.append_action(ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: repo.archive_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::ArchiveScript, working_dir: Some(repo.name.clone()), }), None, )); } Some(root_action) } /// Attempts to run the archive script for a workspace if configured. /// Silently returns Ok if no archive script is configured or if conditions aren't met. async fn try_run_archive_script(&self, workspace_id: Uuid) -> Result<(), ContainerError> { let pool = &self.db().pool; let workspace = Workspace::find_by_id(pool, workspace_id) .await? .ok_or(ContainerError::Other(anyhow!("Workspace not found")))?; if ExecutionProcess::has_running_non_dev_server_processes_for_workspace(pool, workspace.id) .await .unwrap_or(true) { return Ok(()); } if self.ensure_container_exists(&workspace).await.is_err() { return Ok(()); } let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let Some(action) = self.archive_actions_for_repos(&repos) else { return Ok(()); }; let session = match Session::find_latest_by_workspace_id(pool, workspace.id).await? { Some(s) => s, None => { Session::create( pool, &CreateSession { executor: None, name: None, }, Uuid::new_v4(), workspace.id, ) .await? } }; self.start_execution( &workspace, &session, &action, &ExecutionProcessRunReason::ArchiveScript, ) .await?; Ok(()) } /// Archive a workspace: set archived flag, stop running dev servers, and run archive script. async fn archive_workspace(&self, workspace_id: Uuid) -> Result<(), ContainerError> { let pool = &self.db().pool; Workspace::set_archived(pool, workspace_id, true).await?; // Stop running dev servers if let Ok(dev_servers) = ExecutionProcess::find_running_dev_servers_by_workspace(pool, workspace_id).await { for dev_server in dev_servers { if let Err(e) = self .stop_execution(&dev_server, ExecutionProcessStatus::Killed) .await { tracing::error!( "Failed to stop dev server {} for workspace {}: {}", dev_server.id, workspace_id, e ); } } } // Run archive script (silently skips if not configured) if let Err(e) = self.try_run_archive_script(workspace_id).await { tracing::error!( "Failed to run archive script for workspace {}: {}", workspace_id, e ); } Ok(()) } fn setup_actions_for_repos(&self, repos: &[Repo]) -> Option { let repos_with_setup: Vec<_> = repos.iter().filter(|r| r.setup_script.is_some()).collect(); if repos_with_setup.is_empty() { return None; } let mut iter = repos_with_setup.iter(); let first = iter.next()?; let mut root_action = ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: first.setup_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::SetupScript, working_dir: Some(first.name.clone()), }), None, ); for repo in iter { root_action = root_action.append_action(ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: repo.setup_script.clone().unwrap(), language: ScriptRequestLanguage::Bash, context: ScriptContext::SetupScript, working_dir: Some(repo.name.clone()), }), None, )); } Some(root_action) } fn setup_action_for_repo(repo: &Repo) -> Option { repo.setup_script.as_ref().map(|script| { ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: script.clone(), language: ScriptRequestLanguage::Bash, context: ScriptContext::SetupScript, working_dir: Some(repo.name.clone()), }), None, ) }) } fn build_sequential_setup_chain( repos: &[&Repo], next_action: ExecutorAction, ) -> ExecutorAction { let mut chained = next_action; for repo in repos.iter().rev() { if let Some(script) = &repo.setup_script { chained = ExecutorAction::new( ExecutorActionType::ScriptRequest(ScriptRequest { script: script.clone(), language: ScriptRequestLanguage::Bash, context: ScriptContext::SetupScript, working_dir: Some(repo.name.clone()), }), Some(Box::new(chained)), ); } } chained } /// Reset a session to a specific process: restore worktrees, stop processes, drop later processes. async fn reset_session_to_process( &self, session_id: Uuid, target_process_id: Uuid, perform_git_reset: bool, force_when_dirty: bool, ) -> Result<(), ContainerError> { let pool = &self.db().pool; let process = ExecutionProcess::find_by_id(pool, target_process_id) .await? .ok_or_else(|| ContainerError::Other(anyhow!("Process not found")))?; if process.session_id != session_id { return Err(ContainerError::Other(anyhow!( "Process does not belong to this session" ))); } let session = Session::find_by_id(pool, session_id) .await? .ok_or_else(|| ContainerError::Other(anyhow!("Session not found")))?; let workspace = Workspace::find_by_id(pool, session.workspace_id) .await? .ok_or_else(|| ContainerError::Other(anyhow!("Workspace not found")))?; let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; let repo_states = ExecutionProcessRepoState::find_by_execution_process_id(pool, target_process_id) .await?; let container_ref = self.ensure_container_exists(&workspace).await?; let workspace_dir = std::path::PathBuf::from(container_ref); let is_dirty = self .is_container_clean(&workspace) .await .map(|is_clean| !is_clean) .unwrap_or(false); for repo in &repos { let repo_state = repo_states.iter().find(|s| s.repo_id == repo.id); let target_oid = match repo_state.and_then(|s| s.before_head_commit.clone()) { Some(oid) => Some(oid), None => { ExecutionProcess::find_prev_after_head_commit( pool, session_id, target_process_id, repo.id, ) .await? } }; let worktree_path = workspace_dir.join(&repo.name); if let Some(oid) = target_oid { self.git().reconcile_worktree_to_commit( &worktree_path, &oid, git::WorktreeResetOptions::new( perform_git_reset, force_when_dirty, is_dirty, perform_git_reset, ), ); } } self.try_stop(&workspace, false).await; ExecutionProcess::drop_at_and_after(pool, session_id, target_process_id).await?; Ok(()) } async fn try_stop(&self, workspace: &Workspace, include_dev_server: bool) { // stop execution processes for this workspace's sessions let sessions = match Session::find_by_workspace_id(&self.db().pool, workspace.id).await { Ok(s) => s, Err(_) => return, }; for session in sessions { if let Ok(processes) = ExecutionProcess::find_by_session_id(&self.db().pool, session.id, false).await { for process in processes { // Skip dev server processes unless explicitly included if !include_dev_server && process.run_reason == ExecutionProcessRunReason::DevServer { continue; } if process.status == ExecutionProcessStatus::Running { self.stop_execution(&process, ExecutionProcessStatus::Killed) .await .unwrap_or_else(|e| { tracing::debug!( "Failed to stop execution process {} for workspace {}: {}", process.id, workspace.id, e ); }); } } } } } async fn ensure_container_exists( &self, workspace: &Workspace, ) -> Result; async fn is_container_clean(&self, workspace: &Workspace) -> Result; async fn start_execution_inner( &self, workspace: &Workspace, execution_process: &ExecutionProcess, executor_action: &ExecutorAction, ) -> Result<(), ContainerError>; async fn stop_execution( &self, execution_process: &ExecutionProcess, status: ExecutionProcessStatus, ) -> Result<(), ContainerError>; async fn try_commit_changes(&self, ctx: &ExecutionContext) -> Result; async fn copy_project_files( &self, source_dir: &Path, target_dir: &Path, copy_files: &str, ) -> Result<(), ContainerError>; /// Stream diff updates as LogMsg for WebSocket endpoints. async fn stream_diff( &self, workspace: &Workspace, stats_only: bool, ) -> Result>, ContainerError>; /// Fetch the MsgStore for a given execution ID, panicking if missing. async fn get_msg_store_by_id(&self, uuid: &Uuid) -> Option> { let map = self.msg_stores().read().await; map.get(uuid).cloned() } async fn git_branch_prefix(&self) -> String; async fn git_branch_from_workspace(&self, workspace_id: &Uuid, task_title: &str) -> String { let task_title_id = git_branch_id(task_title); let prefix = self.git_branch_prefix().await; if prefix.is_empty() { format!("{}-{}", short_uuid(workspace_id), task_title_id) } else { format!("{}/{}-{}", prefix, short_uuid(workspace_id), task_title_id) } } async fn stream_raw_logs( &self, id: &Uuid, ) -> Option>> { if let Some(store) = self.get_msg_store_by_id(id).await { // First try in-memory store return Some( store .history_plus_stream() .filter(|msg| { future::ready(matches!( msg, Ok(LogMsg::Stdout(..) | LogMsg::Stderr(..) | LogMsg::Finished) )) }) .boxed(), ); } else { let messages = execution_process::load_raw_log_messages(&self.db().pool, *id).await?; let stream = futures::stream::iter( messages .into_iter() .filter(|m| matches!(m, LogMsg::Stdout(_) | LogMsg::Stderr(_))) .chain(std::iter::once(LogMsg::Finished)) .map(Ok::<_, std::io::Error>), ) .boxed(); Some(stream) } } async fn stream_normalized_logs( &self, id: &Uuid, ) -> Option>> { // First try in-memory store (existing behavior) if let Some(store) = self.get_msg_store_by_id(id).await { Some( store .history_plus_stream() // BoxStream> .filter(|msg| future::ready(matches!(msg, Ok(LogMsg::JsonPatch(..))))) .chain(futures::stream::once(async { Ok::<_, std::io::Error>(LogMsg::Finished) })) .boxed(), ) } else { let raw_messages = execution_process::load_raw_log_messages(&self.db().pool, *id).await?; // Create temporary store and populate // Include JsonPatch messages (already normalized) and Stdout/Stderr (need normalization) let temp_store = Arc::new(MsgStore::new()); for msg in raw_messages { if matches!( msg, LogMsg::Stdout(_) | LogMsg::Stderr(_) | LogMsg::JsonPatch(_) ) { temp_store.push(msg); } } temp_store.push_finished(); let process = match ExecutionProcess::find_by_id(&self.db().pool, *id).await { Ok(Some(process)) => process, Ok(None) => { tracing::error!("No execution process found for ID: {}", id); return None; } Err(e) => { tracing::error!("Failed to fetch execution process {}: {}", id, e); return None; } }; // Get the workspace to determine correct directory let (workspace, _session) = match process.parent_workspace_and_session(&self.db().pool).await { Ok(Some((workspace, session))) => (workspace, session), Ok(None) => { tracing::error!( "No workspace/session found for session ID: {}", process.session_id ); return None; } Err(e) => { tracing::error!( "Failed to fetch workspace for session {}: {}", process.session_id, e ); return None; } }; if let Err(err) = self.ensure_container_exists(&workspace).await { tracing::warn!( "Failed to recreate worktree before log normalization for workspace {}: {}", workspace.id, err ); } let current_dir = self.workspace_to_current_dir(&workspace); let executor_action = if let Ok(executor_action) = process.executor_action() { executor_action } else { tracing::error!( "Failed to parse executor action: {:?}", process.executor_action() ); return None; }; // Spawn normalizer on populated store and collect JoinHandles let handles = match executor_action.typ() { ExecutorActionType::CodingAgentInitialRequest(request) => { #[cfg(feature = "qa-mode")] { let executor = QaMockExecutor; executor.normalize_logs( temp_store.clone(), &request.effective_dir(¤t_dir), ) } #[cfg(not(feature = "qa-mode"))] { let executor = ExecutorConfigs::get_cached() .get_coding_agent_or_default(&request.executor_config.profile_id()); executor.normalize_logs( temp_store.clone(), &request.effective_dir(¤t_dir), ) } } ExecutorActionType::CodingAgentFollowUpRequest(request) => { #[cfg(feature = "qa-mode")] { let executor = QaMockExecutor; executor.normalize_logs( temp_store.clone(), &request.effective_dir(¤t_dir), ) } #[cfg(not(feature = "qa-mode"))] { let executor = ExecutorConfigs::get_cached() .get_coding_agent_or_default(&request.executor_config.profile_id()); executor.normalize_logs( temp_store.clone(), &request.effective_dir(¤t_dir), ) } } #[cfg(feature = "qa-mode")] ExecutorActionType::ReviewRequest(_request) => { let executor = QaMockExecutor; executor.normalize_logs(temp_store.clone(), ¤t_dir) } #[cfg(not(feature = "qa-mode"))] ExecutorActionType::ReviewRequest(request) => { let executor = ExecutorConfigs::get_cached() .get_coding_agent_or_default(&request.executor_config.profile_id()); executor.normalize_logs(temp_store.clone(), ¤t_dir) } _ => { tracing::debug!( "Executor action doesn't support log normalization: {:?}", process.executor_action() ); return None; } }; // Await all normalizer tasks, then push Ready so the dedup // stream knows when to flush its buffer and terminate. { let store = temp_store.clone(); tokio::spawn(async move { for handle in handles { let _ = handle.await; } store.push(LogMsg::Ready); }); } // Stream normalized patches, deduplicating consecutive patches // that target the same path (only the final state matters for // historical replay). The Ready sentinel flushes the buffer. enum PatchOrDone { Patch(Patch), Done, } let stream = temp_store .history_plus_stream() .filter_map(|msg| async move { match msg { Ok(LogMsg::JsonPatch(patch)) => Some(PatchOrDone::Patch(patch)), Ok(LogMsg::Ready) => Some(PatchOrDone::Done), _ => None, } }); let deduped = futures::stream::unfold( (stream.boxed(), None::, HashSet::::new()), |(mut stream, buffered, mut sent_paths)| async move { match stream.next().await { Some(PatchOrDone::Patch(patch)) => { let Some(prev) = buffered else { // First patch — just buffer it return Some((None, (stream, Some(patch), sent_paths))); }; if patch_entry_path(&patch) == patch_entry_path(&prev) && is_add_or_replace(&patch) && is_add_or_replace(&prev) { // Same path, both add/replace — replace buffer Some((None, (stream, Some(patch), sent_paths))) } else { // Different — emit prev, buffer new let prev = fix_patch_ops(prev, &mut sent_paths); Some((Some(prev), (stream, Some(patch), sent_paths))) } } Some(PatchOrDone::Done) | None => { // Sentinel or stream end: flush buffer and terminate if let Some(prev) = buffered { let prev = fix_patch_ops(prev, &mut sent_paths); return Some((Some(prev), (stream, None, sent_paths))); } None } } }, ) .filter_map(|opt| async move { opt }) .map(|p| Ok::<_, std::io::Error>(LogMsg::JsonPatch(p))) .chain(futures::stream::once(async { Ok::<_, std::io::Error>(LogMsg::Finished) })); Some(deduped.boxed()) } } async fn start_workspace( &self, workspace: &Workspace, executor_config: ExecutorConfig, prompt: String, ) -> Result { // Create container self.create(workspace).await?; let repos = WorkspaceRepo::find_repos_for_workspace(&self.db().pool, workspace.id).await?; let workspace = Workspace::find_by_id(&self.db().pool, workspace.id) .await? .ok_or(SqlxError::RowNotFound)?; // Create a session for this workspace let session = Session::create( &self.db().pool, &CreateSession { executor: Some(executor_config.executor.to_string()), name: None, }, Uuid::new_v4(), workspace.id, ) .await?; let repos_with_setup: Vec<_> = repos.iter().filter(|r| r.setup_script.is_some()).collect(); let all_parallel = repos_with_setup.iter().all(|r| r.parallel_setup_script); let cleanup_action = self.cleanup_actions_for_repos(&repos); let working_dir = session .agent_working_dir .as_ref() .filter(|dir| !dir.is_empty()) .cloned(); let coding_action = ExecutorAction::new( ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { prompt, executor_config: executor_config.clone(), working_dir, }), cleanup_action.map(Box::new), ); let execution_process = if all_parallel { // All parallel: start each setup independently, then start coding agent for repo in &repos_with_setup { if let Some(action) = Self::setup_action_for_repo(repo) && let Err(e) = self .start_execution( &workspace, &session, &action, &ExecutionProcessRunReason::SetupScript, ) .await { tracing::warn!(?e, "Failed to start setup script in parallel mode"); } } self.start_execution( &workspace, &session, &coding_action, &ExecutionProcessRunReason::CodingAgent, ) .await? } else { // Any sequential: chain ALL setups → coding agent via next_action let main_action = Self::build_sequential_setup_chain(&repos_with_setup, coding_action); self.start_execution( &workspace, &session, &main_action, &ExecutionProcessRunReason::SetupScript, ) .await? }; Ok(execution_process) } async fn start_execution( &self, workspace: &Workspace, session: &Session, executor_action: &ExecutorAction, run_reason: &ExecutionProcessRunReason, ) -> Result { // Create new execution process record // Capture current HEAD per repository as the "before" commit for this execution let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db().pool, workspace.id).await?; if repositories.is_empty() { return Err(ContainerError::Other(anyhow!( "Workspace has no repositories configured" ))); } let workspace_root = workspace .container_ref .as_ref() .map(std::path::PathBuf::from) .ok_or_else(|| ContainerError::Other(anyhow!("Container ref not found")))?; let mut repo_states = Vec::with_capacity(repositories.len()); for repo in &repositories { let repo_path = workspace_root.join(&repo.name); let before_head_commit = self.git().get_head_info(&repo_path).ok().map(|h| h.oid); repo_states.push(CreateExecutionProcessRepoState { repo_id: repo.id, before_head_commit, after_head_commit: None, merge_commit: None, }); } let create_execution_process = CreateExecutionProcess { session_id: session.id, executor_action: executor_action.clone(), run_reason: run_reason.clone(), }; let execution_process = ExecutionProcess::create( &self.db().pool, &create_execution_process, Uuid::new_v4(), &repo_states, ) .await?; if *run_reason != ExecutionProcessRunReason::ArchiveScript { Workspace::set_archived(&self.db().pool, workspace.id, false).await?; } if let Some(prompt) = match executor_action.typ() { ExecutorActionType::CodingAgentInitialRequest(coding_agent_request) => { Some(coding_agent_request.prompt.clone()) } ExecutorActionType::CodingAgentFollowUpRequest(follow_up_request) => { Some(follow_up_request.prompt.clone()) } ExecutorActionType::ReviewRequest(review_request) => { Some(review_request.prompt.clone()) } ExecutorActionType::ScriptRequest(_) => None, } { let create_coding_agent_turn = CreateCodingAgentTurn { execution_process_id: execution_process.id, prompt: Some(prompt), }; let coding_agent_turn_id = Uuid::new_v4(); CodingAgentTurn::create( &self.db().pool, &create_coding_agent_turn, coding_agent_turn_id, ) .await?; } if let Err(start_error) = self .start_execution_inner(workspace, &execution_process, executor_action) .await { // Mark process as failed if let Err(update_error) = ExecutionProcess::update_completion( &self.db().pool, execution_process.id, ExecutionProcessStatus::Failed, None, ) .await { tracing::error!( "Failed to mark execution process {} as failed after start error: {}", execution_process.id, update_error ); } // Emit stderr error message let log_message = LogMsg::Stderr(format!("Failed to start execution: {start_error}")); if let Err(e) = execution_process::append_log_message( session.id, execution_process.id, &log_message, ) .await { tracing::error!( "Failed to write error log for execution {}: {}", execution_process.id, e ); } // Emit NextAction with failure context for coding agent requests if let ContainerError::ExecutorError(ExecutorError::ExecutableNotFound { program }) = &start_error { let help_text = format!("The required executable `{program}` is not installed."); let error_message = NormalizedEntry { timestamp: None, entry_type: NormalizedEntryType::ErrorMessage { error_type: NormalizedEntryError::SetupRequired, }, content: help_text, metadata: None, }; let patch = ConversationPatch::add_normalized_entry(2, error_message); if let Err(e) = execution_process::append_log_message( session.id, execution_process.id, &LogMsg::JsonPatch(patch), ) .await { tracing::error!( "Failed to write setup-required log for execution {}: {}", execution_process.id, e ); } }; return Err(start_error); } // Start processing normalised logs for executor requests and follow ups let workspace_root = self.workspace_to_current_dir(workspace); #[cfg_attr(feature = "qa-mode", allow(unused_variables))] if let Some(msg_store) = self.get_msg_store_by_id(&execution_process.id).await && let Some((executor_profile_id, working_dir)) = match executor_action.typ() { ExecutorActionType::CodingAgentInitialRequest(request) => Some(( request.executor_config.profile_id(), request.effective_dir(&workspace_root), )), ExecutorActionType::CodingAgentFollowUpRequest(request) => Some(( request.executor_config.profile_id(), request.effective_dir(&workspace_root), )), ExecutorActionType::ReviewRequest(request) => Some(( request.executor_config.profile_id(), request.effective_dir(&workspace_root), )), _ => None, } { #[cfg(feature = "qa-mode")] { let executor = QaMockExecutor; let _ = executor.normalize_logs(msg_store, &working_dir); } #[cfg(not(feature = "qa-mode"))] { if let Some(executor) = ExecutorConfigs::get_cached().get_coding_agent(&executor_profile_id) { let _ = executor.normalize_logs(msg_store, &working_dir); } else { tracing::error!( "Failed to resolve profile '{:?}' for normalization", executor_profile_id ); } } } execution_process::spawn_stream_raw_logs_to_storage( self.msg_stores().clone(), self.db().clone(), execution_process.id, session.id, ); Ok(execution_process) } async fn try_start_next_action(&self, ctx: &ExecutionContext) -> Result<(), ContainerError> { let action = ctx.execution_process.executor_action()?; let next_action = if let Some(next_action) = action.next_action() { next_action } else { tracing::debug!("No next action configured"); return Ok(()); }; // Determine the run reason of the next action let next_run_reason = match (action.typ(), next_action.typ()) { (ExecutorActionType::ScriptRequest(_), ExecutorActionType::ScriptRequest(_)) => { ExecutionProcessRunReason::SetupScript } ( ExecutorActionType::CodingAgentInitialRequest(_) | ExecutorActionType::CodingAgentFollowUpRequest(_) | ExecutorActionType::ReviewRequest(_), ExecutorActionType::ScriptRequest(_), ) => ExecutionProcessRunReason::CleanupScript, ( _, ExecutorActionType::CodingAgentFollowUpRequest(_) | ExecutorActionType::CodingAgentInitialRequest(_) | ExecutorActionType::ReviewRequest(_), ) => ExecutionProcessRunReason::CodingAgent, }; self.start_execution(&ctx.workspace, &ctx.session, next_action, &next_run_reason) .await?; tracing::debug!("Started next action: {:?}", next_action); Ok(()) } } ================================================ FILE: crates/services/src/services/diff_stream.rs ================================================ use std::{ collections::HashSet, io, path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, time::Duration, }; use db::{ DBService, models::{workspace::Workspace, workspace_repo::WorkspaceRepo}, }; use executors::logs::utils::{ConversationPatch, patch::escape_json_pointer_segment}; use futures::StreamExt; use git::{Commit, DiffTarget, GitService, GitServiceError}; use notify::{RecommendedWatcher, RecursiveMode}; use notify_debouncer_full::{ DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache, new_debouncer, }; use sqlx::SqlitePool; use thiserror::Error; use tokio::{sync::mpsc, task::JoinHandle}; use tokio_stream::wrappers::{IntervalStream, ReceiverStream}; use utils::{ diff::{self, Diff}, log_msg::LogMsg, }; use uuid::Uuid; use crate::services::filesystem_watcher::{self, FilesystemWatcherError}; #[derive(Debug, Clone, Default)] pub struct DiffStats { pub files_changed: usize, pub lines_added: usize, pub lines_removed: usize, } /// Computes diff stats for a workspace by comparing against target branches. pub async fn compute_diff_stats( pool: &SqlitePool, git: &GitService, workspace: &Workspace, ) -> Option { let container_ref = workspace.container_ref.as_ref()?; let workspace_repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(pool, workspace.id) .await .ok()?; let mut stats = DiffStats::default(); for repo_with_branch in workspace_repos { let worktree_path = PathBuf::from(container_ref).join(&repo_with_branch.repo.name); let repo_path = repo_with_branch.repo.path.clone(); let base_commit_result = tokio::task::spawn_blocking({ let git = git.clone(); let repo_path = repo_path.clone(); let workspace_branch = workspace.branch.clone(); let target_branch = repo_with_branch.target_branch.clone(); move || git.get_base_commit(&repo_path, &workspace_branch, &target_branch) }) .await; let base_commit = match base_commit_result { Ok(Ok(commit)) => commit, _ => continue, }; let diffs_result = tokio::task::spawn_blocking({ let git = git.clone(); let worktree = worktree_path.clone(); move || { git.get_diffs( DiffTarget::Worktree { worktree_path: &worktree, base_commit: &base_commit, }, None, ) } }) .await; if let Ok(Ok(diffs)) = diffs_result { for diff in diffs { stats.files_changed += 1; stats.lines_added += diff.additions.unwrap_or(0); stats.lines_removed += diff.deletions.unwrap_or(0); } } } Some(stats) } /// Maximum cumulative diff bytes to stream before omitting content (200MB) pub const MAX_CUMULATIVE_DIFF_BYTES: usize = 200 * 1024 * 1024; const DIFF_STREAM_CHANNEL_CAPACITY: usize = 1000; /// Errors that can occur during diff stream creation and operation #[derive(Error, Debug)] pub enum DiffStreamError { #[error("Git service error: {0}")] GitService(#[from] GitServiceError), #[error("Filesystem watcher error: {0}")] FilesystemWatcher(#[from] FilesystemWatcherError), #[error("Task join error: {0}")] TaskJoin(#[from] tokio::task::JoinError), #[error("IO error: {0}")] Io(#[from] io::Error), #[error("Notify error: {0}")] Notify(#[from] notify::Error), } impl DiffStreamError { /// Returns true if this error is caused by a git repository not being found /// (e.g. the worktree directory was deleted while the diff stream was running). fn is_repo_not_found(&self) -> bool { matches!( self, DiffStreamError::GitService(GitServiceError::Git(git_err)) if git_err.code() == git2::ErrorCode::NotFound && git_err.class() == git2::ErrorClass::Repository ) } } /// Diff stream that owns the filesystem watcher task /// When this stream is dropped, the watcher is automatically cleaned up pub struct DiffStreamHandle { stream: futures::stream::BoxStream<'static, Result>, _watcher_task: Option>, } impl futures::Stream for DiffStreamHandle { type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { // Delegate to inner stream std::pin::Pin::new(&mut self.stream).poll_next(cx) } } impl Drop for DiffStreamHandle { fn drop(&mut self) { if let Some(handle) = self._watcher_task.take() { handle.abort(); } } } impl DiffStreamHandle { /// Create a new DiffStreamHandle from a boxed stream and optional watcher task pub fn new( stream: futures::stream::BoxStream<'static, Result>, watcher_task: Option>, ) -> Self { Self { stream, _watcher_task: watcher_task, } } } #[derive(Clone)] pub struct DiffStreamArgs { pub git_service: GitService, pub db: DBService, pub workspace_id: Uuid, pub repo_id: Uuid, pub repo_path: PathBuf, pub worktree_path: PathBuf, pub branch: String, pub target_branch: String, pub base_commit: Commit, pub stats_only: bool, pub path_prefix: Option, } struct DiffStreamManager { args: DiffStreamArgs, tx: mpsc::Sender>, cumulative: Arc, known_paths: Arc>>, full_sent: Arc>>, current_base_commit: Commit, current_target_branch: String, } enum DiffEvent { Filesystem(DebounceEventResult), GitStateChange, CheckTarget, } pub async fn create(args: DiffStreamArgs) -> Result { let (tx, rx) = mpsc::channel::>(DIFF_STREAM_CHANNEL_CAPACITY); let manager_args = args.clone(); let watcher_task = tokio::spawn(async move { let mut manager = DiffStreamManager::new(manager_args, tx); if let Err(e) = manager.run().await { if e.is_repo_not_found() { tracing::warn!("Diff stream ended: repository no longer found"); } else { tracing::error!("Diff stream manager failed: {e}"); } let _ = manager.tx.send(Err(io::Error::other(e.to_string()))).await; } }); Ok(DiffStreamHandle::new( ReceiverStream::new(rx).boxed(), Some(watcher_task), )) } impl DiffStreamManager { fn new(args: DiffStreamArgs, tx: mpsc::Sender>) -> Self { Self { current_base_commit: args.base_commit.clone(), current_target_branch: args.target_branch.clone(), args, tx, cumulative: Arc::new(AtomicUsize::new(0)), known_paths: Arc::new(std::sync::RwLock::new(HashSet::new())), full_sent: Arc::new(std::sync::RwLock::new(HashSet::new())), } } async fn run(&mut self) -> Result<(), DiffStreamError> { self.reset_stream().await?; // Send Ready message to indicate initial data has been sent let _ready_error = self.tx.send(Ok(LogMsg::Ready)).await; let (fs_debouncer, mut fs_rx, canonical_worktree) = filesystem_watcher::async_watcher(self.args.worktree_path.clone()) .map_err(|e| io::Error::other(e.to_string()))?; let _fs_guard = fs_debouncer; let (git_debouncer, mut git_rx) = match setup_git_watcher(&self.args.git_service, &self.args.worktree_path) { Some((d, rx)) => (Some(d), Some(rx)), None => (None, None), }; let _git_guard = git_debouncer; let mut target_interval = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))); loop { let event = tokio::select! { Some(res) = fs_rx.next() => DiffEvent::Filesystem(res), Ok(()) = async { match git_rx.as_mut() { Some(rx) => rx.changed().await, None => std::future::pending().await, } } => DiffEvent::GitStateChange, _ = target_interval.next() => DiffEvent::CheckTarget, else => break, }; match event { DiffEvent::Filesystem(res) => match res { Ok(events) => { self.handle_fs_events(events, &canonical_worktree).await?; } Err(e) => { tracing::error!("Filesystem watcher error: {e:?}"); return Err(io::Error::other(format!("{e:?}")).into()); } }, DiffEvent::GitStateChange => { self.handle_git_state_change().await?; } DiffEvent::CheckTarget => { self.handle_target_check().await?; } } } Ok(()) } async fn reset_stream(&mut self) -> Result<(), DiffStreamError> { let paths_to_clear: Vec = { let mut guard = self.known_paths.write().unwrap(); guard.drain().collect() }; for raw_path in paths_to_clear { let prefixed = prefix_path(raw_path, self.args.path_prefix.as_deref()); let patch = ConversationPatch::remove_diff(escape_json_pointer_segment(&prefixed)); if self.tx.send(Ok(LogMsg::JsonPatch(patch))).await.is_err() { return Ok(()); } } self.cumulative.store(0, Ordering::Relaxed); self.full_sent.write().unwrap().clear(); let diffs = self.fetch_diffs().await?; self.send_diffs(diffs).await?; Ok(()) } async fn fetch_diffs(&self) -> Result, DiffStreamError> { let git = self.args.git_service.clone(); let worktree = self.args.worktree_path.clone(); let base = self.current_base_commit.clone(); let stats_only = self.args.stats_only; let cumulative = self.cumulative.clone(); tokio::task::spawn_blocking(move || { let diffs = git.get_diffs( DiffTarget::Worktree { worktree_path: &worktree, base_commit: &base, }, None, )?; let mut processed_diffs = Vec::with_capacity(diffs.len()); for mut diff in diffs { apply_stream_omit_policy(&mut diff, &cumulative, stats_only); processed_diffs.push(diff); } Ok(processed_diffs) }) .await? } async fn send_diffs(&self, diffs: Vec) -> Result<(), DiffStreamError> { for mut diff in diffs { let raw_path = GitService::diff_path(&diff); { let mut guard = self.known_paths.write().unwrap(); guard.insert(raw_path.clone()); } if !diff.content_omitted { let mut guard = self.full_sent.write().unwrap(); guard.insert(raw_path.clone()); } let prefixed_entry = prefix_path(raw_path, self.args.path_prefix.as_deref()); if let Some(old) = diff.old_path { diff.old_path = Some(prefix_path(old, self.args.path_prefix.as_deref())); } if let Some(new) = diff.new_path { diff.new_path = Some(prefix_path(new, self.args.path_prefix.as_deref())); } diff.repo_id = Some(self.args.repo_id); let patch = ConversationPatch::add_diff(escape_json_pointer_segment(&prefixed_entry), diff); if self.tx.send(Ok(LogMsg::JsonPatch(patch))).await.is_err() { return Ok(()); } } Ok(()) } async fn handle_fs_events( &self, events: Vec, canonical_worktree: &Path, ) -> Result<(), DiffStreamError> { let changed_paths = extract_changed_paths(&events, canonical_worktree, &self.args.worktree_path); if changed_paths.is_empty() { return Ok(()); } let git = self.args.git_service.clone(); let worktree = self.args.worktree_path.clone(); let base = self.current_base_commit.clone(); let cumulative = self.cumulative.clone(); let full_sent = self.full_sent.clone(); let known_paths = self.known_paths.clone(); let stats_only = self.args.stats_only; let prefix = self.args.path_prefix.clone(); let repo_id = self.args.repo_id; let messages = tokio::task::spawn_blocking(move || { process_file_changes( &git, &worktree, &base, &changed_paths, &cumulative, &full_sent, &known_paths, stats_only, prefix.as_deref(), repo_id, ) }) .await??; for msg in messages { if self.tx.send(Ok(msg)).await.is_err() { return Ok(()); } } Ok(()) } async fn handle_git_state_change(&mut self) -> Result<(), DiffStreamError> { let Some(new_base) = self .recompute_base_commit(&self.current_target_branch) .await else { return Ok(()); }; if new_base.as_oid() != self.current_base_commit.as_oid() { self.current_base_commit = new_base; self.reset_stream().await?; } Ok(()) } async fn handle_target_check(&mut self) -> Result<(), DiffStreamError> { let Ok(Some(repo)) = WorkspaceRepo::find_by_workspace_and_repo_id( &self.args.db.pool, self.args.workspace_id, self.args.repo_id, ) .await else { return Ok(()); }; if repo.target_branch != self.current_target_branch && let Some(new_base) = self.recompute_base_commit(&repo.target_branch).await { self.current_target_branch = repo.target_branch; self.current_base_commit = new_base; self.reset_stream().await?; } Ok(()) } async fn recompute_base_commit(&self, target_branch: &str) -> Option { let git = self.args.git_service.clone(); let repo_path = self.args.repo_path.clone(); let branch = self.args.branch.clone(); let target = target_branch.to_string(); tokio::task::spawn_blocking(move || git.get_base_commit(&repo_path, &branch, &target).ok()) .await .ok() .flatten() } } fn prefix_path(path: String, prefix: Option<&str>) -> String { match prefix { Some(p) => format!("{p}/{path}"), None => path, } } pub fn apply_stream_omit_policy(diff: &mut Diff, sent_bytes: &Arc, stats_only: bool) { if stats_only { omit_diff_contents(diff); return; } let mut size = 0usize; if let Some(ref s) = diff.old_content { size += s.len(); } if let Some(ref s) = diff.new_content { size += s.len(); } if size == 0 { return; } let current = sent_bytes.load(Ordering::Relaxed); if current.saturating_add(size) > MAX_CUMULATIVE_DIFF_BYTES { omit_diff_contents(diff); } else { let _ = sent_bytes.fetch_add(size, Ordering::Relaxed); } } fn omit_diff_contents(diff: &mut Diff) { if diff.additions.is_none() && diff.deletions.is_none() && (diff.old_content.is_some() || diff.new_content.is_some()) { let old = diff.old_content.as_deref().unwrap_or(""); let new = diff.new_content.as_deref().unwrap_or(""); let (add, del) = diff::compute_line_change_counts(old, new); diff.additions = Some(add); diff.deletions = Some(del); } diff.old_content = None; diff.new_content = None; diff.content_omitted = true; } fn extract_changed_paths( events: &[DebouncedEvent], canonical_worktree_path: &Path, worktree_path: &Path, ) -> Vec { events .iter() .flat_map(|event| &event.paths) .filter_map(|path| { path.strip_prefix(canonical_worktree_path) .or_else(|_| path.strip_prefix(worktree_path)) .ok() .map(|p| p.to_string_lossy().replace('\\', "/")) }) .filter(|s| !s.is_empty()) .collect() } #[allow(clippy::too_many_arguments)] fn process_file_changes( git_service: &GitService, worktree_path: &Path, base_commit: &Commit, changed_paths: &[String], cumulative_bytes: &Arc, full_sent_paths: &Arc>>, known_paths: &Arc>>, stats_only: bool, path_prefix: Option<&str>, repo_id: Uuid, ) -> Result, DiffStreamError> { let path_filter: Vec<&str> = changed_paths.iter().map(|s| s.as_str()).collect(); let current_diffs = git_service.get_diffs( DiffTarget::Worktree { worktree_path, base_commit, }, Some(&path_filter), )?; let mut msgs = Vec::new(); let mut files_with_diffs = HashSet::new(); for mut diff in current_diffs { let raw_file_path = GitService::diff_path(&diff); files_with_diffs.insert(raw_file_path.clone()); { let mut guard = known_paths.write().unwrap(); guard.insert(raw_file_path.clone()); } apply_stream_omit_policy(&mut diff, cumulative_bytes, stats_only); if diff.content_omitted { if full_sent_paths.read().unwrap().contains(&raw_file_path) { continue; } } else { let mut guard = full_sent_paths.write().unwrap(); guard.insert(raw_file_path.clone()); } let prefixed_entry_index = prefix_path(raw_file_path, path_prefix); if let Some(old) = diff.old_path { diff.old_path = Some(prefix_path(old, path_prefix)); } if let Some(new) = diff.new_path { diff.new_path = Some(prefix_path(new, path_prefix)); } diff.repo_id = Some(repo_id); let patch = ConversationPatch::add_diff(escape_json_pointer_segment(&prefixed_entry_index), diff); msgs.push(LogMsg::JsonPatch(patch)); } for changed_path in changed_paths { if !files_with_diffs.contains(changed_path) { let prefixed_path = prefix_path(changed_path.clone(), path_prefix); let patch = ConversationPatch::remove_diff(escape_json_pointer_segment(&prefixed_path)); msgs.push(LogMsg::JsonPatch(patch)); { let mut guard = known_paths.write().unwrap(); guard.remove(changed_path); } } } Ok(msgs) } /// Watches `.git/HEAD` and `.git/logs/HEAD` for changes. /// Correctly resolves gitdir even for worktrees. fn setup_git_watcher( git: &GitService, worktree_path: &Path, ) -> Option<( Debouncer, tokio::sync::watch::Receiver<()>, )> { let Ok(repo) = git.open_repo(worktree_path) else { tracing::warn!( "Failed to open git repo at {:?}, git events will be ignored", worktree_path ); return None; }; // For worktrees, repo.path() points to the actual gitdir (e.g. .git/worktrees/name or .git/) let gitdir = repo.path(); let paths_to_watch = vec![gitdir.join("HEAD"), gitdir.join("logs").join("HEAD")]; let (tx, rx) = tokio::sync::watch::channel(()); // Create debouncer with short timeout since git operations might touch multiple files let mut debouncer = new_debouncer( Duration::from_millis(200), None, move |res: DebounceEventResult| { if res.is_ok() { let _ = tx.send(()); } }, ) .ok()?; let mut watched_any = false; for path in paths_to_watch { if path.exists() { if let Err(e) = debouncer.watch(&path, RecursiveMode::NonRecursive) { tracing::debug!("Failed to watch git path {:?}: {}", path, e); } else { watched_any = true; } } } if !watched_any { return None; } Some((debouncer, rx)) } ================================================ FILE: crates/services/src/services/events/patches.rs ================================================ use db::models::{ execution_process::ExecutionProcess, scratch::Scratch, workspace::WorkspaceWithStatus, }; use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation}; use uuid::Uuid; // Shared helper to escape JSON Pointer segments fn escape_pointer_segment(s: &str) -> String { s.replace('~', "~0").replace('/', "~1") } /// Helper functions for creating execution process-specific patches pub mod execution_process_patch { use super::*; fn execution_process_path(process_id: Uuid) -> String { format!( "/execution_processes/{}", escape_pointer_segment(&process_id.to_string()) ) } /// Create patch for adding a new execution process pub fn add(process: &ExecutionProcess) -> Patch { Patch(vec![PatchOperation::Add(AddOperation { path: execution_process_path(process.id) .try_into() .expect("Execution process path should be valid"), value: serde_json::to_value(process) .expect("Execution process serialization should not fail"), })]) } /// Create patch for updating an existing execution process pub fn replace(process: &ExecutionProcess) -> Patch { Patch(vec![PatchOperation::Replace(ReplaceOperation { path: execution_process_path(process.id) .try_into() .expect("Execution process path should be valid"), value: serde_json::to_value(process) .expect("Execution process serialization should not fail"), })]) } /// Create patch for removing an execution process pub fn remove(process_id: Uuid) -> Patch { Patch(vec![PatchOperation::Remove(RemoveOperation { path: execution_process_path(process_id) .try_into() .expect("Execution process path should be valid"), })]) } } /// Helper functions for creating workspace-specific patches pub mod workspace_patch { use super::*; fn workspace_path(workspace_id: Uuid) -> String { format!( "/workspaces/{}", escape_pointer_segment(&workspace_id.to_string()) ) } pub fn add(workspace: &WorkspaceWithStatus) -> Patch { Patch(vec![PatchOperation::Add(AddOperation { path: workspace_path(workspace.id) .try_into() .expect("Workspace path should be valid"), value: serde_json::to_value(workspace) .expect("Workspace serialization should not fail"), })]) } pub fn replace(workspace: &WorkspaceWithStatus) -> Patch { Patch(vec![PatchOperation::Replace(ReplaceOperation { path: workspace_path(workspace.id) .try_into() .expect("Workspace path should be valid"), value: serde_json::to_value(workspace) .expect("Workspace serialization should not fail"), })]) } pub fn remove(workspace_id: Uuid) -> Patch { Patch(vec![PatchOperation::Remove(RemoveOperation { path: workspace_path(workspace_id) .try_into() .expect("Workspace path should be valid"), })]) } } /// Helper functions for creating scratch-specific patches. /// All patches use path "/scratch" - filtering is done by matching id and payload type in the value. pub mod scratch_patch { use super::*; const SCRATCH_PATH: &str = "/scratch"; /// Create patch for adding a new scratch pub fn add(scratch: &Scratch) -> Patch { Patch(vec![PatchOperation::Add(AddOperation { path: SCRATCH_PATH .try_into() .expect("Scratch path should be valid"), value: serde_json::to_value(scratch).expect("Scratch serialization should not fail"), })]) } /// Create patch for updating an existing scratch pub fn replace(scratch: &Scratch) -> Patch { Patch(vec![PatchOperation::Replace(ReplaceOperation { path: SCRATCH_PATH .try_into() .expect("Scratch path should be valid"), value: serde_json::to_value(scratch).expect("Scratch serialization should not fail"), })]) } /// Create patch for removing a scratch. /// Uses Replace with deleted marker so clients can filter by id and payload type. pub fn remove(scratch_id: Uuid, scratch_type_str: &str) -> Patch { Patch(vec![PatchOperation::Replace(ReplaceOperation { path: SCRATCH_PATH .try_into() .expect("Scratch path should be valid"), value: serde_json::json!({ "id": scratch_id, "payload": { "type": scratch_type_str }, "deleted": true }), })]) } } /// Helper functions for creating approval-specific patches. pub mod approvals_patch { use super::*; const PENDING_PATH: &str = "/pending"; fn pending_path(approval_id: &str) -> String { format!("{}/{}", PENDING_PATH, escape_pointer_segment(approval_id)) } pub fn snapshot(pending: &[crate::services::approvals::ApprovalInfo]) -> Patch { let pending: serde_json::Map = pending .iter() .map(|info| { ( info.approval_id.clone(), serde_json::to_value(info).unwrap_or(serde_json::Value::Null), ) }) .collect(); Patch(vec![PatchOperation::Replace(ReplaceOperation { path: PENDING_PATH .try_into() .expect("Pending approvals path should be valid"), value: serde_json::Value::Object(pending), })]) } pub fn created(info: &crate::services::approvals::ApprovalInfo) -> Patch { let value = serde_json::to_value(info).unwrap_or(serde_json::Value::Null); Patch(vec![PatchOperation::Replace(ReplaceOperation { path: pending_path(&info.approval_id) .try_into() .expect("Approval path should be valid"), value, })]) } pub fn resolved(approval_id: &str) -> Patch { Patch(vec![PatchOperation::Remove(RemoveOperation { path: pending_path(approval_id) .try_into() .expect("Approval path should be valid"), })]) } } ================================================ FILE: crates/services/src/services/events/streams.rs ================================================ use db::models::{execution_process::ExecutionProcess, scratch::Scratch, workspace::Workspace}; use futures::StreamExt; use serde_json::json; use tokio_stream::wrappers::BroadcastStream; use utils::log_msg::LogMsg; use uuid::Uuid; use super::{ EventService, patches::execution_process_patch, types::{EventPatch, RecordTypes}, }; impl EventService { /// Stream execution processes for a specific session with initial snapshot (raw LogMsg format for WebSocket) pub async fn stream_execution_processes_for_session_raw( &self, session_id: Uuid, show_soft_deleted: bool, ) -> Result< futures::stream::BoxStream<'static, Result>, super::types::EventError, > { // Get execution processes for this session let processes = ExecutionProcess::find_by_session_id(&self.db.pool, session_id, show_soft_deleted) .await?; // Convert processes array to object keyed by process ID let processes_map: serde_json::Map = processes .into_iter() .map(|process| { ( process.id.to_string(), serde_json::to_value(process).unwrap(), ) }) .collect(); let initial_patch = json!([{ "op": "replace", "path": "/execution_processes", "value": processes_map }]); let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap()); // Get filtered event stream let filtered_stream = BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| { async move { match msg_result { Ok(LogMsg::JsonPatch(patch)) => { // Filter events based on session_id if let Some(patch_op) = patch.0.first() { // Check if this is a modern execution process patch if patch_op.path().starts_with("/execution_processes/") { match patch_op { json_patch::PatchOperation::Add(op) => { // Parse execution process data directly from value if let Ok(process) = serde_json::from_value::( op.value.clone(), ) && process.session_id == session_id { if !show_soft_deleted && process.dropped { let remove_patch = execution_process_patch::remove(process.id); return Some(Ok(LogMsg::JsonPatch( remove_patch, ))); } return Some(Ok(LogMsg::JsonPatch(patch))); } } json_patch::PatchOperation::Replace(op) => { // Parse execution process data directly from value if let Ok(process) = serde_json::from_value::( op.value.clone(), ) && process.session_id == session_id { if !show_soft_deleted && process.dropped { let remove_patch = execution_process_patch::remove(process.id); return Some(Ok(LogMsg::JsonPatch( remove_patch, ))); } return Some(Ok(LogMsg::JsonPatch(patch))); } } json_patch::PatchOperation::Remove(_) => { // For remove operations, we can't verify session_id // so we allow all removals and let the client handle filtering return Some(Ok(LogMsg::JsonPatch(patch))); } _ => {} } } // Fallback to legacy EventPatch format for backward compatibility else if let Ok(event_patch_value) = serde_json::to_value(patch_op) && let Ok(event_patch) = serde_json::from_value::(event_patch_value) { match &event_patch.value.record { RecordTypes::ExecutionProcess(process) => { if process.session_id == session_id { if !show_soft_deleted && process.dropped { let remove_patch = execution_process_patch::remove(process.id); return Some(Ok(LogMsg::JsonPatch( remove_patch, ))); } return Some(Ok(LogMsg::JsonPatch(patch))); } } RecordTypes::DeletedExecutionProcess { session_id: Some(deleted_session_id), .. } => { if *deleted_session_id == session_id { return Some(Ok(LogMsg::JsonPatch(patch))); } } _ => {} } } } None } Ok(other) => Some(Ok(other)), // Pass through non-patch messages Err(_) => None, // Filter out broadcast errors } } }); // Start with initial snapshot, Ready signal, then live updates let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]); let combined_stream = initial_stream.chain(filtered_stream).boxed(); Ok(combined_stream) } /// Stream a single scratch item with initial snapshot (raw LogMsg format for WebSocket) pub async fn stream_scratch_raw( &self, scratch_id: Uuid, scratch_type: &db::models::scratch::ScratchType, ) -> Result< futures::stream::BoxStream<'static, Result>, super::types::EventError, > { // Treat errors (e.g., corrupted/malformed data) the same as "scratch not found" // This prevents the websocket from closing and retrying indefinitely let scratch = match Scratch::find_by_id(&self.db.pool, scratch_id, scratch_type).await { Ok(scratch) => scratch, Err(e) => { tracing::warn!( scratch_id = %scratch_id, scratch_type = %scratch_type, error = %e, "Failed to load scratch, treating as empty" ); None } }; let initial_patch = json!([{ "op": "replace", "path": "/scratch", "value": scratch }]); let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap()); let type_str = scratch_type.to_string(); // Filter to only this scratch's events by matching id and payload.type in the patch value let filtered_stream = BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| { let id_str = scratch_id.to_string(); let type_str = type_str.clone(); async move { match msg_result { Ok(LogMsg::JsonPatch(patch)) => { if let Some(op) = patch.0.first() && op.path() == "/scratch" { // Extract id and payload.type from the patch value let value = match op { json_patch::PatchOperation::Add(a) => Some(&a.value), json_patch::PatchOperation::Replace(r) => Some(&r.value), json_patch::PatchOperation::Remove(_) => None, _ => None, }; let matches = value.is_some_and(|v| { let id_matches = v.get("id").and_then(|v| v.as_str()) == Some(&id_str); let type_matches = v .get("payload") .and_then(|p| p.get("type")) .and_then(|t| t.as_str()) == Some(&type_str); id_matches && type_matches }); if matches { return Some(Ok(LogMsg::JsonPatch(patch))); } } None } Ok(other) => Some(Ok(other)), Err(_) => None, } } }); let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]); let combined_stream = initial_stream.chain(filtered_stream).boxed(); Ok(combined_stream) } pub async fn stream_workspaces_raw( &self, archived: Option, limit: Option, ) -> Result< futures::stream::BoxStream<'static, Result>, super::types::EventError, > { let workspaces = Workspace::find_all_with_status(&self.db.pool, archived, limit).await?; let workspaces_map: serde_json::Map = workspaces .into_iter() .map(|ws| (ws.id.to_string(), serde_json::to_value(ws).unwrap())) .collect(); let initial_patch = json!([{ "op": "replace", "path": "/workspaces", "value": workspaces_map }]); let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap()); let filtered_stream = BroadcastStream::new(self.msg_store.get_receiver()).filter_map( move |msg_result| async move { match msg_result { Ok(LogMsg::JsonPatch(patch)) => { if let Some(op) = patch.0.first() && op.path().starts_with("/workspaces") { // If archived filter is set, handle state transitions if let Some(archived_filter) = archived { // Extract workspace data from Add/Replace operations let value = match op { json_patch::PatchOperation::Add(a) => Some(&a.value), json_patch::PatchOperation::Replace(r) => Some(&r.value), json_patch::PatchOperation::Remove(_) => { // Allow remove operations through - client will handle return Some(Ok(LogMsg::JsonPatch(patch))); } _ => None, }; if let Some(v) = value && let Some(ws_archived) = v.get("archived").and_then(|a| a.as_bool()) { if ws_archived == archived_filter { // Workspace matches this filter // Convert Replace to Add since workspace may be new to this filtered stream if let json_patch::PatchOperation::Replace(r) = op { let add_patch = json_patch::Patch(vec![ json_patch::PatchOperation::Add( json_patch::AddOperation { path: r.path.clone(), value: r.value.clone(), }, ), ]); return Some(Ok(LogMsg::JsonPatch(add_patch))); } return Some(Ok(LogMsg::JsonPatch(patch))); } else { // Workspace no longer matches this filter - send remove let remove_patch = json_patch::Patch(vec![ json_patch::PatchOperation::Remove( json_patch::RemoveOperation { path: op .path() .to_string() .try_into() .expect("Workspace path should be valid"), }, ), ]); return Some(Ok(LogMsg::JsonPatch(remove_patch))); } } } return Some(Ok(LogMsg::JsonPatch(patch))); } None } Ok(other) => Some(Ok(other)), Err(_) => None, } }, ); let initial_stream = futures::stream::iter(vec![Ok(initial_msg), Ok(LogMsg::Ready)]); Ok(initial_stream.chain(filtered_stream).boxed()) } } ================================================ FILE: crates/services/src/services/events/types.rs ================================================ use anyhow::Error as AnyhowError; use db::models::{execution_process::ExecutionProcess, scratch::Scratch, workspace::Workspace}; use serde::{Deserialize, Serialize}; use sqlx::Error as SqlxError; use strum_macros::{Display, EnumString}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Error)] pub enum EventError { #[error(transparent)] Sqlx(#[from] SqlxError), #[error(transparent)] Parse(#[from] serde_json::Error), #[error(transparent)] Other(#[from] AnyhowError), // Catches any unclassified errors } #[derive(EnumString, Display)] pub enum HookTables { #[strum(to_string = "workspaces")] Workspaces, #[strum(to_string = "execution_processes")] ExecutionProcesses, #[strum(to_string = "scratch")] Scratch, } #[derive(Serialize, Deserialize, TS)] #[serde(tag = "type", content = "data", rename_all = "SCREAMING_SNAKE_CASE")] pub enum RecordTypes { Workspace(Workspace), ExecutionProcess(ExecutionProcess), Scratch(Scratch), DeletedWorkspace { rowid: i64, }, DeletedExecutionProcess { rowid: i64, session_id: Option, process_id: Option, }, DeletedScratch { rowid: i64, scratch_id: Option, scratch_type: Option, }, } #[derive(Serialize, Deserialize, TS)] pub struct EventPatchInner { pub(crate) db_op: String, pub(crate) record: RecordTypes, } #[derive(Serialize, Deserialize, TS)] pub struct EventPatch { pub(crate) op: String, pub(crate) path: String, pub(crate) value: EventPatchInner, } ================================================ FILE: crates/services/src/services/events.rs ================================================ use std::{str::FromStr, sync::Arc}; use db::{ DBService, models::{ execution_process::ExecutionProcess, scratch::Scratch, session::Session, workspace::Workspace, }, }; use serde_json::json; use sqlx::{Error as SqlxError, Sqlite, SqlitePool, decode::Decode, sqlite::SqliteOperation}; use tokio::sync::RwLock; use utils::msg_store::MsgStore; use uuid::Uuid; #[path = "events/patches.rs"] pub mod patches; #[path = "events/streams.rs"] mod streams; #[path = "events/types.rs"] pub mod types; pub use patches::{execution_process_patch, scratch_patch, workspace_patch}; pub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes}; #[derive(Clone)] pub struct EventService { msg_store: Arc, db: DBService, #[allow(dead_code)] entry_count: Arc>, } impl EventService { /// Creates a new EventService that will work with a DBService configured with hooks pub fn new(db: DBService, msg_store: Arc, entry_count: Arc>) -> Self { Self { msg_store, db, entry_count, } } async fn push_workspace_update_for_session( pool: &SqlitePool, msg_store: Arc, session_id: Uuid, ) -> Result<(), SqlxError> { if let Some(session) = Session::find_by_id(pool, session_id).await? && let Some(workspace_with_status) = Workspace::find_by_id_with_status(pool, session.workspace_id).await? { msg_store.push_patch(workspace_patch::replace(&workspace_with_status)); } Ok(()) } /// Creates the hook function that should be used with DBService::new_with_after_connect pub fn create_hook( msg_store: Arc, entry_count: Arc>, db_service: DBService, ) -> impl for<'a> Fn( &'a mut sqlx::sqlite::SqliteConnection, ) -> std::pin::Pin< Box> + Send + 'a>, > + Send + Sync + 'static { move |conn: &mut sqlx::sqlite::SqliteConnection| { let msg_store_for_hook = msg_store.clone(); let entry_count_for_hook = entry_count.clone(); let db_for_hook = db_service.clone(); Box::pin(async move { let mut handle = conn.lock_handle().await?; let runtime_handle = tokio::runtime::Handle::current(); handle.set_preupdate_hook({ let msg_store_for_preupdate = msg_store_for_hook.clone(); move |preupdate: sqlx::sqlite::PreupdateHookResult<'_>| { if preupdate.operation != SqliteOperation::Delete { return; } match preupdate.table { "workspaces" => { if let Ok(value) = preupdate.get_old_column_value(0) && let Ok(workspace_id) = >::decode(value) { let patch = workspace_patch::remove(workspace_id); msg_store_for_preupdate.push_patch(patch); } } "execution_processes" => { if let Ok(value) = preupdate.get_old_column_value(0) && let Ok(process_id) = >::decode(value) { let patch = execution_process_patch::remove(process_id); msg_store_for_preupdate.push_patch(patch); } } "scratch" => { // Composite key: need both id (column 0) and scratch_type (column 1) if let Ok(id_val) = preupdate.get_old_column_value(0) && let Ok(scratch_id) = >::decode(id_val) && let Ok(type_val) = preupdate.get_old_column_value(1) && let Ok(type_str) = >::decode(type_val) { let patch = scratch_patch::remove(scratch_id, &type_str); msg_store_for_preupdate.push_patch(patch); } } _ => {} } } }); handle.set_update_hook(move |hook: sqlx::sqlite::UpdateHookResult<'_>| { let runtime_handle = runtime_handle.clone(); let entry_count_for_hook = entry_count_for_hook.clone(); let msg_store_for_hook = msg_store_for_hook.clone(); let db = db_for_hook.clone(); if let Ok(table) = HookTables::from_str(hook.table) { let rowid = hook.rowid; runtime_handle.spawn(async move { let record_type: RecordTypes = match (table, hook.operation.clone()) { (HookTables::Workspaces, SqliteOperation::Delete) | (HookTables::ExecutionProcesses, SqliteOperation::Delete) | (HookTables::Scratch, SqliteOperation::Delete) => { return; } (HookTables::Workspaces, _) => { match Workspace::find_by_rowid(&db.pool, rowid).await { Ok(Some(workspace)) => RecordTypes::Workspace(workspace), Ok(None) => RecordTypes::DeletedWorkspace { rowid, }, Err(e) => { tracing::error!( "Failed to fetch workspace: {:?}", e ); return; } } } (HookTables::ExecutionProcesses, _) => { match ExecutionProcess::find_by_rowid(&db.pool, rowid).await { Ok(Some(process)) => RecordTypes::ExecutionProcess(process), Ok(None) => RecordTypes::DeletedExecutionProcess { rowid, session_id: None, process_id: None, }, Err(e) => { tracing::error!( "Failed to fetch execution_process: {:?}", e ); return; } } } (HookTables::Scratch, _) => { match Scratch::find_by_rowid(&db.pool, rowid).await { Ok(Some(scratch)) => RecordTypes::Scratch(scratch), Ok(None) => RecordTypes::DeletedScratch { rowid, scratch_id: None, scratch_type: None, }, Err(e) => { tracing::error!("Failed to fetch scratch: {:?}", e); return; } } } }; let db_op: &str = match hook.operation { SqliteOperation::Insert => "insert", SqliteOperation::Delete => "delete", SqliteOperation::Update => "update", SqliteOperation::Unknown(_) => "unknown", }; // Handle operations with direct patches match &record_type { RecordTypes::Scratch(scratch) => { let patch = match hook.operation { SqliteOperation::Insert => scratch_patch::add(scratch), SqliteOperation::Update => scratch_patch::replace(scratch), _ => scratch_patch::replace(scratch), }; msg_store_for_hook.push_patch(patch); return; } RecordTypes::DeletedScratch { scratch_id: Some(scratch_id), scratch_type: Some(scratch_type_str), .. } => { let patch = scratch_patch::remove(*scratch_id, scratch_type_str); msg_store_for_hook.push_patch(patch); return; } RecordTypes::Workspace(workspace) => { // Emit workspace patch with status if let Ok(Some(workspace_with_status)) = Workspace::find_by_id_with_status(&db.pool, workspace.id) .await { let patch = match hook.operation { SqliteOperation::Insert => { workspace_patch::add(&workspace_with_status) } _ => workspace_patch::replace(&workspace_with_status), }; msg_store_for_hook.push_patch(patch); } return; } RecordTypes::DeletedWorkspace { .. } => { return; } RecordTypes::ExecutionProcess(process) => { let patch = match hook.operation { SqliteOperation::Insert => { execution_process_patch::add(process) } SqliteOperation::Update => { execution_process_patch::replace(process) } _ => execution_process_patch::replace(process), // fallback }; msg_store_for_hook.push_patch(patch); if let Err(err) = EventService::push_workspace_update_for_session( &db.pool, msg_store_for_hook.clone(), process.session_id, ) .await { tracing::error!( "Failed to push workspace update after execution process change: {:?}", err ); } return; } RecordTypes::DeletedExecutionProcess { process_id: Some(process_id), session_id, .. } => { let patch = execution_process_patch::remove(*process_id); msg_store_for_hook.push_patch(patch); if let Some(session_id) = session_id && let Err(err) = EventService::push_workspace_update_for_session( &db.pool, msg_store_for_hook.clone(), *session_id, ) .await { tracing::error!( "Failed to push workspace update after execution process removal: {:?}", err ); } return; } _ => {} } // Fallback: use the old entries format for other record types let next_entry_count = { let mut entry_count = entry_count_for_hook.write().await; *entry_count += 1; *entry_count }; let event_patch: EventPatch = EventPatch { op: "add".to_string(), path: format!("/entries/{next_entry_count}"), value: EventPatchInner { db_op: db_op.to_string(), record: record_type, }, }; let patch = serde_json::from_value(json!([ serde_json::to_value(event_patch).unwrap() ])) .unwrap(); msg_store_for_hook.push_patch(patch); }); } }); Ok(()) }) } } pub fn msg_store(&self) -> &Arc { &self.msg_store } } ================================================ FILE: crates/services/src/services/execution_process.rs ================================================ use std::{ collections::HashMap, io::{IsTerminal, Write}, sync::Arc, }; use anyhow::{Context, Result}; use db::{ DBService, models::{ coding_agent_turn::CodingAgentTurn, execution_process::ExecutionProcess, execution_process_logs::ExecutionProcessLogs, }, }; use futures::{StreamExt, TryStreamExt}; use indicatif::{ProgressBar, ProgressStyle}; use sqlx::SqlitePool; use tokio::{io::AsyncWriteExt, sync::RwLock, task::JoinHandle}; use utils::{ assets::prod_asset_dir_path, execution_logs::{ ExecutionLogWriter, process_log_file_path, process_log_file_path_in_root, read_execution_log_file, }, log_msg::LogMsg, msg_store::MsgStore, }; use uuid::Uuid; pub async fn migrate_execution_logs_to_files() -> Result<()> { let pool = DBService::new_migration_pool() .await .map_err(|e| anyhow::anyhow!("Migration DB pool error: {}", e))?; if !ExecutionProcessLogs::has_any(&pool).await? { return Ok(()); } let is_tty = std::io::stderr().is_terminal(); if is_tty { let _ = writeln!( std::io::stderr(), "Performing one time database migration to move logs from SQLite to flat file to improve performance, data remains local, may take a few minutes, please don't exit while this process is running..." ); } let pb = if is_tty { Some(new_spinner("Migrating")) } else { None }; let total_processes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let count_task = { let pool = pool.clone(); let pb = pb.clone(); let total_processes = total_processes.clone(); tokio::spawn(async move { if let Ok(count) = ExecutionProcessLogs::count_distinct_processes(&pool).await { total_processes.store(count as usize, std::sync::atomic::Ordering::Relaxed); if let Some(pb) = pb { pb.set_length(count as u64); pb.set_style( ProgressStyle::default_bar() .template("{bar:36.yellow} {percent:>3}% {msg:<12.dim}") .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("■⬝"), ); } } }) }; let completed = Arc::new(std::sync::atomic::AtomicUsize::new(0)); ExecutionProcessLogs::stream_distinct_processes(&pool) .map_err(anyhow::Error::from) .map(|res| { let pool = pool.clone(); let pb = pb.clone(); let completed = completed.clone(); let total_processes = total_processes.clone(); async move { let p = res?; let path = process_log_file_path(p.session_id, p.execution_id); if path.exists() { if let Some(pb) = &pb { pb.inc(1); } return Ok::<(), anyhow::Error>(()); } if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } let temp_path = path.with_extension("jsonl.tmp"); let mut file = tokio::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&temp_path) .await?; let mut logs_stream = ExecutionProcessLogs::stream_log_lines_by_execution_id(&pool, &p.execution_id); let mut has_logs = false; while let Some(log_res) = logs_stream.next().await { let log = log_res?; has_logs = true; let mut line = log; if !line.ends_with('\n') { line.push('\n'); } file.write_all(line.as_bytes()).await?; } if !has_logs { let _ = tokio::fs::remove_file(&temp_path).await; if let Some(pb) = &pb { pb.inc(1); } return Ok::<(), anyhow::Error>(()); } file.sync_all().await?; tokio::fs::rename(temp_path, path).await?; let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if let Some(pb) = &pb { pb.inc(1); } else if c.is_multiple_of(100) { let t = total_processes.load(std::sync::atomic::Ordering::Relaxed); let _ = writeln!( std::io::stderr(), "sqlite-migration:{}", if t > 0 { (c * 100 / t).to_string() } else { "?".to_string() } ); } Ok::<(), anyhow::Error>(()) } }) .buffer_unordered(64) .try_collect::>() .await?; let _ = count_task.await; if let Some(pb) = pb { pb.finish_and_clear(); } else { let _ = writeln!(std::io::stderr(), "sqlite-migration:done"); } let vacuum_pb = if is_tty { Some(new_spinner("Compacting")) } else { let _ = writeln!(std::io::stderr(), "Compacting database..."); None }; ExecutionProcessLogs::delete_all(&pool).await?; sqlx::query("VACUUM").execute(&pool).await?; if let Some(pb) = vacuum_pb { pb.finish_and_clear(); } let _ = writeln!(std::io::stderr(), "Database migration complete."); pool.close().await; Ok(()) } pub async fn remove_session_process_logs(session_id: Uuid) -> Result<()> { let dir = utils::execution_logs::process_logs_session_dir(session_id); match tokio::fs::remove_dir_all(&dir).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => { Err(e).with_context(|| format!("remove session process logs at {}", dir.display())) } } } pub async fn load_raw_log_messages(pool: &SqlitePool, execution_id: Uuid) -> Option> { if let Some(jsonl) = read_execution_logs_for_execution(pool, execution_id) .await .inspect_err(|e| { tracing::warn!( "Failed to read execution log file for execution {}: {:#}", execution_id, e ); }) .ok() .flatten() { let messages = utils::execution_logs::parse_log_jsonl_lossy(execution_id, &jsonl); if !messages.is_empty() { return Some(messages); } } let db_log_records = match ExecutionProcessLogs::find_by_execution_id(pool, execution_id).await { Ok(records) if !records.is_empty() => records, Ok(_) => return None, Err(e) => { tracing::error!( "Failed to fetch DB logs for execution {}: {}", execution_id, e ); return None; } }; match ExecutionProcessLogs::parse_logs(&db_log_records) { Ok(msgs) => Some(msgs), Err(e) => { tracing::error!( "Failed to parse DB logs for execution {}: {}", execution_id, e ); None } } } pub async fn append_log_message(session_id: Uuid, execution_id: Uuid, msg: &LogMsg) -> Result<()> { let mut log_writer = ExecutionLogWriter::new_for_execution(session_id, execution_id) .await .with_context(|| format!("create log writer for execution {}", execution_id))?; let json_line = serde_json::to_string(msg) .with_context(|| format!("serialize log message for execution {}", execution_id))?; let mut json_line_with_newline = json_line; json_line_with_newline.push('\n'); log_writer .append_jsonl_line(&json_line_with_newline) .await .with_context(|| format!("append log message for execution {}", execution_id))?; Ok(()) } pub fn spawn_stream_raw_logs_to_storage( msg_stores: Arc>>>, db: DBService, execution_id: Uuid, session_id: Uuid, ) -> JoinHandle<()> { tokio::spawn(async move { let mut log_writer = match ExecutionLogWriter::new_for_execution(session_id, execution_id).await { Ok(w) => w, Err(e) => { tracing::error!( "Failed to create log file writer for execution {}: {}", execution_id, e ); return; } }; let store = { let map = msg_stores.read().await; map.get(&execution_id).cloned() }; if let Some(store) = store { let mut stream = store.history_plus_stream(); while let Some(Ok(msg)) = stream.next().await { match &msg { LogMsg::Stdout(_) | LogMsg::Stderr(_) => match serde_json::to_string(&msg) { Ok(jsonl_line) => { let mut jsonl_line_with_newline = jsonl_line; jsonl_line_with_newline.push('\n'); if let Err(e) = log_writer.append_jsonl_line(&jsonl_line_with_newline).await { tracing::error!( "Failed to append log line for execution {}: {}", execution_id, e ); } } Err(e) => { tracing::error!( "Failed to serialize log message for execution {}: {}", execution_id, e ); } }, LogMsg::SessionId(agent_session_id) => { if let Err(e) = CodingAgentTurn::update_agent_session_id( &db.pool, execution_id, agent_session_id, ) .await { tracing::error!( "Failed to update agent_session_id {} for execution process {}: {}", agent_session_id, execution_id, e ); } } LogMsg::MessageId(agent_message_id) => { if let Err(e) = CodingAgentTurn::update_agent_message_id( &db.pool, execution_id, agent_message_id, ) .await { tracing::error!( "Failed to update agent_message_id {} for execution process {}: {}", agent_message_id, execution_id, e ); } } LogMsg::Finished => { break; } LogMsg::JsonPatch(_) | LogMsg::Ready => continue, } } } }) } async fn read_execution_logs_for_execution( pool: &SqlitePool, execution_id: Uuid, ) -> Result> { let session_id = if let Some(process) = ExecutionProcess::find_by_id(pool, execution_id).await? { process.session_id } else { return Ok(None); }; let path = process_log_file_path(session_id, execution_id); match tokio::fs::metadata(&path).await { Ok(_) => Ok(Some(read_execution_log_file(&path).await.with_context( || format!("read execution log file for execution {execution_id}"), )?)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if cfg!(debug_assertions) { // Convenience for local development with a clone of a prod db. Read only access to prod logs. let prod_path = process_log_file_path_in_root(&prod_asset_dir_path(), session_id, execution_id); match read_execution_log_file(&prod_path).await { Ok(contents) => return Ok(Some(contents)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => { return Err(err).with_context(|| { format!( "read execution log file for execution {execution_id} from {}", prod_path.display() ) }); } } } Ok(None) } Err(e) => Err(e).with_context(|| { format!( "check execution log file exists for execution {execution_id} at {}", path.display() ) }), } } fn new_spinner(message: &'static str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() .template("{spinner:.yellow} {msg:<12.dim}") .unwrap_or_else(|_| ProgressStyle::default_spinner()) .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), ); pb.set_message(message); pb.enable_steady_tick(std::time::Duration::from_millis(100)); pb } ================================================ FILE: crates/services/src/services/file.rs ================================================ use std::{ fs, path::{Path, PathBuf}, }; use db::models::file::{CreateFile, File}; use mime_guess::MimeGuess; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum FileError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Database error: {0}")] Database(#[from] sqlx::Error), #[error("File too large: {0} bytes (max: {1} bytes)")] TooLarge(u64, u64), #[error("File not found")] NotFound, #[error("Failed to build response: {0}")] ResponseBuildError(String), } /// Sanitize filename for filesystem safety: /// - Lowercase /// - Spaces → underscores /// - Remove special characters (keep alphanumeric and underscores) /// - Truncate if too long fn sanitize_filename(name: &str) -> String { let stem = Path::new(name) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("file"); let clean: String = stem .to_lowercase() .chars() .map(|c| if c.is_whitespace() { '_' } else { c }) .filter(|c| c.is_alphanumeric() || *c == '_') .collect(); // Truncate to reasonable length to avoid filesystem limits let max_len = 50; if clean.len() > max_len { clean[..max_len].to_string() } else if clean.is_empty() { "file".to_string() } else { clean } } #[derive(Clone)] pub struct FileService { cache_dir: PathBuf, legacy_cache_dir: PathBuf, pool: SqlitePool, max_size_bytes: u64, } impl FileService { pub fn new(pool: SqlitePool) -> Result { let cache_dir = utils::cache_dir().join("attachments"); let legacy_cache_dir = utils::cache_dir().join("images"); fs::create_dir_all(&cache_dir)?; Ok(Self { cache_dir, legacy_cache_dir, pool, max_size_bytes: 20 * 1024 * 1024, // 20MB default }) } pub async fn store_file( &self, data: &[u8], original_filename: &str, ) -> Result { let file_size = data.len() as u64; if file_size > self.max_size_bytes { return Err(FileError::TooLarge(file_size, self.max_size_bytes)); } let hash = format!("{:x}", Sha256::digest(data)); let extension = Path::new(original_filename) .extension() .and_then(|e| e.to_str()) .unwrap_or("bin"); let mime_type = MimeGuess::from_path(original_filename) .first_raw() .map(str::to_string) .or_else(|| { MimeGuess::from_ext(extension) .first_raw() .map(str::to_string) }); let existing_file = File::find_by_hash(&self.pool, &hash).await?; if let Some(existing) = existing_file { tracing::debug!("Reusing existing file record with hash {}", hash); return Ok(existing); } let clean_name = sanitize_filename(original_filename); let new_filename = format!("{}_{}.{}", Uuid::new_v4(), clean_name, extension); let cached_path = self.cache_dir.join(&new_filename); fs::write(&cached_path, data)?; let file = File::create( &self.pool, &CreateFile { file_path: new_filename, original_name: original_filename.to_string(), mime_type, size_bytes: file_size as i64, hash, }, ) .await?; Ok(file) } pub async fn delete_orphaned_files(&self) -> Result<(), FileError> { let orphaned_files = File::find_orphaned_files(&self.pool).await?; if orphaned_files.is_empty() { tracing::debug!("No orphaned files found during cleanup"); return Ok(()); } tracing::debug!("Found {} orphaned files to clean up", orphaned_files.len()); let mut deleted_count = 0; let mut failed_count = 0; for file in orphaned_files { match self.delete_file(file.id).await { Ok(_) => { deleted_count += 1; tracing::debug!("Deleted orphaned file: {}", file.id); } Err(e) => { failed_count += 1; tracing::error!("Failed to delete orphaned file {}: {}", file.id, e); } } } tracing::info!( "File cleanup completed: {} deleted, {} failed", deleted_count, failed_count ); Ok(()) } pub fn get_absolute_path(&self, file: &File) -> PathBuf { self.resolve_cached_path(&file.file_path) .unwrap_or_else(|| self.cache_dir.join(&file.file_path)) } pub async fn get_file(&self, id: Uuid) -> Result, FileError> { Ok(File::find_by_id(&self.pool, id).await?) } pub async fn delete_file(&self, id: Uuid) -> Result<(), FileError> { if let Some(file) = File::find_by_id(&self.pool, id).await? { let file_path = self.cache_dir.join(&file.file_path); if file_path.exists() { fs::remove_file(file_path)?; } let legacy_file_path = self.legacy_cache_dir.join(&file.file_path); if legacy_file_path.exists() { fs::remove_file(legacy_file_path)?; } File::delete(&self.pool, id).await?; } Ok(()) } pub async fn copy_files_by_workspace_to_worktree( &self, worktree_path: &Path, workspace_id: Uuid, agent_working_dir: Option<&str>, ) -> Result<(), FileError> { let files = File::find_by_workspace_id(&self.pool, workspace_id).await?; let target_path = match agent_working_dir { Some(dir) if !dir.is_empty() => worktree_path.join(dir), _ => worktree_path.to_path_buf(), }; self.copy_files(&target_path, files) } pub async fn copy_files_by_ids_to_worktree( &self, worktree_path: &Path, file_ids: &[Uuid], ) -> Result<(), FileError> { let mut files = Vec::new(); for id in file_ids { if let Some(file) = File::find_by_id(&self.pool, *id).await? { files.push(file); } } self.copy_files(worktree_path, files) } /// Copy files to the worktree. Skips files that already exist at target. fn copy_files(&self, worktree_path: &Path, files: Vec) -> Result<(), FileError> { if files.is_empty() { return Ok(()); } let attachments_dir = worktree_path.join(utils::path::VIBE_ATTACHMENTS_DIR); // Fast path: check if all files exist before doing anything let all_exist = files .iter() .all(|file| attachments_dir.join(&file.file_path).exists()); if all_exist { return Ok(()); } std::fs::create_dir_all(&attachments_dir)?; // Create .gitignore to ignore all files in this directory let gitignore_path = attachments_dir.join(".gitignore"); if !gitignore_path.exists() { std::fs::write(&gitignore_path, "*\n")?; } for file in files { let src = self .resolve_cached_path(&file.file_path) .unwrap_or_else(|| self.cache_dir.join(&file.file_path)); let dst = attachments_dir.join(&file.file_path); if dst.exists() { continue; } if src.exists() { if let Err(e) = std::fs::copy(&src, &dst) { tracing::error!("Failed to copy {}: {}", file.file_path, e); } else { tracing::debug!("Copied {}", file.file_path); } } else { tracing::warn!("Missing cache file: {}", src.display()); } } Ok(()) } fn resolve_cached_path(&self, file_path: &str) -> Option { let primary = self.cache_dir.join(file_path); if primary.exists() { return Some(primary); } let legacy = self.legacy_cache_dir.join(file_path); if legacy.exists() { tracing::info!( "Using legacy attachment cache path for {}: {}", file_path, legacy.display() ); return Some(legacy); } None } } ================================================ FILE: crates/services/src/services/file_ranker.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use dashmap::DashMap; use db::models::repo::{SearchMatchType, SearchResult}; use git::{FileStat, GitService, GitServiceError}; use once_cell::sync::Lazy; use tokio::task; /// File statistics for a repository pub type FileStats = HashMap; /// Cache entry for repository history #[derive(Clone)] struct RepoHistoryCache { head_sha: String, stats: Arc, } /// Global cache for file ranking statistics static FILE_STATS_CACHE: Lazy> = Lazy::new(DashMap::new); /// Configuration constants for ranking algorithm const DEFAULT_COMMIT_LIMIT: usize = 100; const BASE_MATCH_SCORE_FILENAME: i64 = 100; const BASE_MATCH_SCORE_DIRNAME: i64 = 10; const BASE_MATCH_SCORE_FULLPATH: i64 = 1; const RECENCY_WEIGHT: i64 = 2; const FREQUENCY_WEIGHT: i64 = 1; /// Service for ranking files based on git history #[derive(Clone)] pub struct FileRanker { git_service: GitService, } impl Default for FileRanker { fn default() -> Self { Self::new() } } impl FileRanker { pub fn new() -> Self { Self { git_service: GitService::new(), } } /// Get file statistics for a repository, using cache when possible pub async fn get_stats(&self, repo_path: &Path) -> Result, GitServiceError> { let repo_path = repo_path.to_path_buf(); // Check if we have a valid cache entry if let Some(cache_entry) = FILE_STATS_CACHE.get(&repo_path) { // Verify cache is still valid by checking HEAD if let Ok(head_info) = self.git_service.get_head_info(&repo_path) && head_info.oid == cache_entry.head_sha { return Ok(Arc::clone(&cache_entry.stats)); } } // Cache miss or invalid - compute new stats let stats = self.compute_stats(&repo_path).await?; Ok(stats) } /// Re-rank search results based on git history statistics pub fn rerank(&self, results: &mut [SearchResult], stats: &FileStats) { results.sort_by(|a, b| { let score_a = self.calculate_score(a, stats); let score_b = self.calculate_score(b, stats); score_b.cmp(&score_a) // Higher scores first }); } /// Calculate relevance score for a search result pub fn calculate_score(&self, result: &SearchResult, stats: &FileStats) -> i64 { let base_score = match result.match_type { SearchMatchType::FileName => BASE_MATCH_SCORE_FILENAME, SearchMatchType::DirectoryName => BASE_MATCH_SCORE_DIRNAME, SearchMatchType::FullPath => BASE_MATCH_SCORE_FULLPATH, }; if let Some(stat) = stats.get(&result.path) { let recency_bonus = (100 - stat.last_index.min(99) as i64) * RECENCY_WEIGHT; let frequency_bonus = stat.commit_count as i64 * FREQUENCY_WEIGHT; // Multiply base score to maintain hierarchy, add git-based bonuses base_score * 1000 + recency_bonus * 10 + frequency_bonus } else { // Files not in git history get base score only base_score * 1000 } } /// Compute file statistics from git history async fn compute_stats(&self, repo_path: &Path) -> Result, GitServiceError> { let repo_path = repo_path.to_path_buf(); let repo_path_for_error = repo_path.clone(); let git_service = self.git_service.clone(); // Run git analysis in blocking task to avoid blocking async runtime let stats = task::spawn_blocking(move || { git_service.collect_recent_file_stats(&repo_path, DEFAULT_COMMIT_LIMIT) }) .await .map_err(|e| GitServiceError::InvalidRepository(format!("Task join error: {e}")))?; let stats = match stats { Ok(s) => s, Err(e) => { tracing::warn!( "Failed to collect file stats for {:?}: {}", repo_path_for_error, e ); // Return empty stats on error - search will still work without ranking HashMap::new() } }; let stats_arc = Arc::new(stats); // Update cache if let Ok(head_info) = self.git_service.get_head_info(&repo_path_for_error) { FILE_STATS_CACHE.insert( repo_path_for_error, RepoHistoryCache { head_sha: head_info.oid, stats: Arc::clone(&stats_arc), }, ); } Ok(stats_arc) } } ================================================ FILE: crates/services/src/services/file_search.rs ================================================ use std::{ path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; use dashmap::DashMap; use db::models::repo::{SearchMatchType, SearchResult}; use fst::{Map, MapBuilder}; use git::GitService; use ignore::WalkBuilder; use moka::future::Cache; use notify::{RecommendedWatcher, RecursiveMode}; use notify_debouncer_full::{DebounceEventResult, new_debouncer}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::mpsc; use tracing::{error, info, warn}; use ts_rs::TS; use super::file_ranker::{FileRanker, FileStats}; /// Search mode for different use cases #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum SearchMode { #[default] TaskForm, // Default: exclude ignored files (clean results) Settings, // Include ignored files (for project config like .env) } /// Search query parameters for typed Axum extraction #[derive(Debug, Clone, Deserialize)] pub struct SearchQuery { pub q: String, #[serde(default)] pub mode: SearchMode, } /// FST-indexed file search result #[derive(Clone, Debug)] pub struct IndexedFile { pub path: String, pub is_file: bool, pub match_type: SearchMatchType, pub path_lowercase: Arc, pub is_ignored: bool, // Track if file is gitignored } /// File index build result containing indexed files and FST map #[derive(Debug)] pub struct FileIndex { pub files: Vec, pub map: Map>, } /// Errors that can occur during file index building #[derive(Error, Debug)] pub enum FileIndexError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Fst(#[from] fst::Error), #[error(transparent)] Walk(#[from] ignore::Error), #[error(transparent)] StripPrefix(#[from] std::path::StripPrefixError), } /// Cached repository data with FST index and git stats #[derive(Clone)] pub struct CachedRepo { pub head_sha: String, pub fst_index: Map>, pub indexed_files: Vec, pub stats: Arc, pub build_ts: Instant, } /// Cache miss error #[derive(Debug)] pub enum CacheError { Miss, BuildError(String), } /// File search cache with FST indexing pub struct FileSearchCache { cache: Cache, git_service: GitService, file_ranker: FileRanker, build_queue: mpsc::UnboundedSender, watchers: DashMap, } impl FileSearchCache { pub fn new() -> Self { let (build_sender, build_receiver) = mpsc::unbounded_channel(); // Create cache with 100MB limit and 1 hour TTL let cache = Cache::builder() .max_capacity(50) // Max 50 repos .time_to_live(Duration::from_secs(3600)) // 1 hour TTL .build(); let cache_for_worker = cache.clone(); let git_service = GitService::new(); let file_ranker = FileRanker::new(); // Spawn background worker let worker_git_service = git_service.clone(); let worker_file_ranker = file_ranker.clone(); tokio::spawn(async move { Self::background_worker( build_receiver, cache_for_worker, worker_git_service, worker_file_ranker, ) .await; }); Self { cache, git_service, file_ranker, build_queue: build_sender, watchers: DashMap::new(), } } /// Search files in repository using cache pub async fn search( &self, repo_path: &Path, query: &str, mode: SearchMode, ) -> Result, CacheError> { let repo_path_buf = repo_path.to_path_buf(); // Check if we have a valid cache entry if let Some(cached) = self.cache.get(&repo_path_buf).await && let Ok(head_info) = self.git_service.get_head_info(&repo_path_buf) && head_info.oid == cached.head_sha { // Cache hit - perform fast search with mode-based filtering return Ok(self.search_in_cache(&cached, query, mode).await); } // Cache miss - trigger background refresh and return error if let Err(e) = self.build_queue.send(repo_path_buf) { warn!("Failed to enqueue cache build: {}", e); } Err(CacheError::Miss) } /// Pre-warm cache for given repositories pub async fn warm_repos(&self, repo_paths: Vec) -> Result<(), String> { for repo_path in repo_paths { if let Err(e) = self.build_queue.send(repo_path.clone()) { error!( "Failed to enqueue repo for warming: {:?} - {}", repo_path, e ); } } Ok(()) } /// Search within cached index with mode-based filtering async fn search_in_cache( &self, cached: &CachedRepo, query: &str, mode: SearchMode, ) -> Vec { let query_lower = query.to_lowercase(); let mut results = Vec::new(); // Search through indexed files with mode-based filtering for indexed_file in &cached.indexed_files { if indexed_file.path_lowercase.contains(&query_lower) { // Apply mode-based filtering match mode { SearchMode::TaskForm => { // Exclude ignored files for task forms if indexed_file.is_ignored { continue; } } SearchMode::Settings => { // Include all files (including ignored) for project settings // No filtering needed } } results.push(SearchResult { path: indexed_file.path.clone(), is_file: indexed_file.is_file, match_type: indexed_file.match_type.clone(), score: 0, }); } } // Apply git history-based ranking self.file_ranker.rerank(&mut results, &cached.stats); // Populate scores for sorted results for result in &mut results { result.score = self.file_ranker.calculate_score(result, &cached.stats); } // Limit to top 10 results results.truncate(10); results } /// Search files in a single repository with cache + fallback pub async fn search_repo( &self, repo_path: &Path, query: &str, mode: SearchMode, ) -> Result, String> { let query = query.trim(); if query.is_empty() { return Ok(vec![]); } // Try cache first match self.search(repo_path, query, mode.clone()).await { Ok(results) => Ok(results), Err(CacheError::Miss) | Err(CacheError::BuildError(_)) => { // Fall back to filesystem search self.search_files_no_cache(repo_path, query, mode).await } } } /// Fallback filesystem search when cache is not available async fn search_files_no_cache( &self, repo_path: &Path, query: &str, mode: SearchMode, ) -> Result, String> { if !repo_path.exists() { return Err(format!("Path not found: {:?}", repo_path)); } let mut results = Vec::new(); let query_lower = query.to_lowercase(); let walker = match mode { SearchMode::Settings => { // Settings mode: Include ignored files but exclude performance killers WalkBuilder::new(repo_path) .git_ignore(false) .git_global(false) .git_exclude(false) .hidden(false) .filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); name != ".git" && name != "node_modules" && name != "target" && name != "dist" && name != "build" }) .build() } SearchMode::TaskForm => WalkBuilder::new(repo_path) .git_ignore(true) .git_global(true) .git_exclude(true) .hidden(false) .filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); name != ".git" }) .build(), }; for result in walker { let entry = match result { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); // Skip the root directory itself if path == repo_path { continue; } let relative_path = match path.strip_prefix(repo_path) { Ok(p) => p, Err(_) => continue, }; let relative_path_str = relative_path.to_string_lossy().to_lowercase(); let file_name = path .file_name() .map(|name| name.to_string_lossy().to_lowercase()) .unwrap_or_default(); if file_name.contains(&query_lower) { results.push(SearchResult { path: relative_path.to_string_lossy().to_string(), is_file: path.is_file(), match_type: SearchMatchType::FileName, score: 0, }); } else if relative_path_str.contains(&query_lower) { let match_type = if path .parent() .and_then(|p| p.file_name()) .map(|name| name.to_string_lossy().to_lowercase()) .unwrap_or_default() .contains(&query_lower) { SearchMatchType::DirectoryName } else { SearchMatchType::FullPath }; results.push(SearchResult { path: relative_path.to_string_lossy().to_string(), is_file: path.is_file(), match_type, score: 0, }); } } // Apply git history-based ranking match self.file_ranker.get_stats(repo_path).await { Ok(stats) => { self.file_ranker.rerank(&mut results, &stats); // Populate scores for sorted results for result in &mut results { result.score = self.file_ranker.calculate_score(result, &stats); } } Err(_) => { // Fallback to basic priority sorting results.sort_by(|a, b| { let priority = |match_type: &SearchMatchType| match match_type { SearchMatchType::FileName => 0, SearchMatchType::DirectoryName => 1, SearchMatchType::FullPath => 2, }; priority(&a.match_type) .cmp(&priority(&b.match_type)) .then_with(|| a.path.cmp(&b.path)) }); } } results.truncate(10); Ok(results) } /// Build cache entry for a repository async fn build_repo_cache(&self, repo_path: &Path) -> Result { let repo_path_buf = repo_path.to_path_buf(); info!("Building cache for repo: {:?}", repo_path); // Get current HEAD let head_info = self .git_service .get_head_info(&repo_path_buf) .map_err(|e| format!("Failed to get HEAD info: {e}"))?; // Get git stats let stats = self .file_ranker .get_stats(repo_path) .await .map_err(|e| format!("Failed to get git stats: {e}"))?; // Build file index let file_index = Self::build_file_index(repo_path) .map_err(|e| format!("Failed to build file index: {e}"))?; Ok(CachedRepo { head_sha: head_info.oid, fst_index: file_index.map, indexed_files: file_index.files, stats, build_ts: Instant::now(), }) } /// Build FST index from filesystem traversal using superset approach fn build_file_index(repo_path: &Path) -> Result { let mut indexed_files = Vec::new(); let mut fst_keys = Vec::new(); // Build superset walker - include ignored files but exclude .git and performance killers let mut builder = WalkBuilder::new(repo_path); builder .git_ignore(false) // Include all files initially .git_global(false) .git_exclude(false) .hidden(false) // Show hidden files like .env .filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); // Always exclude .git directories if name == ".git" { return false; } // Exclude performance killers even when including ignored files if name == "node_modules" || name == "target" || name == "dist" || name == "build" { return false; } true }); let walker = builder.build(); // Create a second walker for checking ignore status let ignore_walker = WalkBuilder::new(repo_path) .git_ignore(true) // This will tell us what's ignored .git_global(true) .git_exclude(true) .hidden(false) .filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); name != ".git" }) .build(); // Collect paths from ignore-aware walker to know what's NOT ignored let mut non_ignored_paths = std::collections::HashSet::new(); for result in ignore_walker { if let Ok(entry) = result && let Ok(relative_path) = entry.path().strip_prefix(repo_path) { non_ignored_paths.insert(relative_path.to_path_buf()); } } // Now walk all files and determine their ignore status for result in walker { let entry = result?; let path = entry.path(); if path == repo_path { continue; } let relative_path = path.strip_prefix(repo_path)?; let relative_path_str = relative_path.to_string_lossy().to_string(); let relative_path_lower = relative_path_str.to_lowercase(); // Skip empty paths if relative_path_lower.is_empty() { continue; } // Determine if this file is ignored let is_ignored = !non_ignored_paths.contains(relative_path); let file_name = path .file_name() .map(|name| name.to_string_lossy().to_lowercase()) .unwrap_or_default(); // Determine match type let match_type = if !file_name.is_empty() { SearchMatchType::FileName } else if path .parent() .and_then(|p| p.file_name()) .map(|name| name.to_string_lossy().to_lowercase()) .unwrap_or_default() != relative_path_lower { SearchMatchType::DirectoryName } else { SearchMatchType::FullPath }; let indexed_file = IndexedFile { path: relative_path_str, is_file: path.is_file(), match_type, path_lowercase: Arc::from(relative_path_lower.as_str()), is_ignored, }; // Store the key for FST along with file index let file_index = indexed_files.len() as u64; fst_keys.push((relative_path_lower, file_index)); indexed_files.push(indexed_file); } // Sort keys for FST (required for building) fst_keys.sort_by(|a, b| a.0.cmp(&b.0)); // Remove duplicates (keep first occurrence) fst_keys.dedup_by(|a, b| a.0 == b.0); // Build FST let mut fst_builder = MapBuilder::memory(); for (key, value) in fst_keys { fst_builder.insert(&key, value)?; } let fst_map = fst_builder.into_map(); Ok(FileIndex { files: indexed_files, map: fst_map, }) } /// Background worker for cache building async fn background_worker( mut build_receiver: mpsc::UnboundedReceiver, cache: Cache, git_service: GitService, file_ranker: FileRanker, ) { while let Some(repo_path) = build_receiver.recv().await { if !repo_path.exists() { warn!( "Skipping cache build for non-existent repo path: {:?}", repo_path ); continue; } let cache_builder = FileSearchCache { cache: cache.clone(), git_service: git_service.clone(), file_ranker: file_ranker.clone(), build_queue: mpsc::unbounded_channel().0, // Dummy sender watchers: DashMap::new(), }; match cache_builder.build_repo_cache(&repo_path).await { Ok(cached_repo) => { cache.insert(repo_path.clone(), cached_repo).await; info!("Successfully cached repo: {:?}", repo_path); } Err(e) => { error!("Failed to cache repo {:?}: {}", repo_path, e); } } } } /// Setup file watcher for repository pub async fn setup_watcher(&self, repo_path: &Path) -> Result<(), String> { let repo_path_buf = repo_path.to_path_buf(); if self.watchers.contains_key(&repo_path_buf) { return Ok(()); // Already watching } let git_dir = repo_path.join(".git"); if !git_dir.exists() { return Err("Not a git repository".to_string()); } let build_queue = self.build_queue.clone(); let watched_path = repo_path_buf.clone(); let (tx, mut rx) = mpsc::unbounded_channel(); let mut debouncer = new_debouncer( Duration::from_millis(500), None, move |res: DebounceEventResult| { if let Ok(events) = res { for event in events { // Check if any path contains HEAD file for path in &event.event.paths { if path.file_name().is_some_and(|name| name == "HEAD") { if let Err(e) = tx.send(()) { error!("Failed to send HEAD change event: {}", e); } break; } } } } }, ) .map_err(|e| format!("Failed to create file watcher: {e}"))?; debouncer .watch(git_dir.join("HEAD"), RecursiveMode::NonRecursive) .map_err(|e| format!("Failed to watch HEAD file: {e}"))?; // Spawn task to handle HEAD changes tokio::spawn(async move { while rx.recv().await.is_some() { info!("HEAD changed for repo: {:?}", watched_path); if let Err(e) = build_queue.send(watched_path.clone()) { error!("Failed to enqueue cache refresh: {}", e); } } }); info!("Setup file watcher for repo: {:?}", repo_path); Ok(()) } } impl Default for FileSearchCache { fn default() -> Self { Self::new() } } ================================================ FILE: crates/services/src/services/filesystem.rs ================================================ #[cfg(not(feature = "qa-mode"))] use std::collections::HashSet; use std::{ fs, path::{Path, PathBuf}, }; #[cfg(not(feature = "qa-mode"))] use ignore::WalkBuilder; use serde::Serialize; use thiserror::Error; #[cfg(not(feature = "qa-mode"))] use tokio_util::sync::CancellationToken; use ts_rs::TS; #[derive(Clone)] pub struct FilesystemService {} #[derive(Debug, Error)] pub enum FilesystemError { #[error("Directory does not exist")] DirectoryDoesNotExist, #[error("Path is not a directory")] PathIsNotDirectory, #[error("Failed to read directory: {0}")] Io(#[from] std::io::Error), } #[derive(Debug, Serialize, TS)] pub struct DirectoryListResponse { pub entries: Vec, pub current_path: String, } #[derive(Debug, Serialize, TS)] pub struct DirectoryEntry { pub name: String, pub path: PathBuf, pub is_directory: bool, pub is_git_repo: bool, pub last_modified: Option, } impl Default for FilesystemService { fn default() -> Self { Self::new() } } impl FilesystemService { pub fn new() -> Self { FilesystemService {} } #[cfg(not(feature = "qa-mode"))] fn get_directories_to_skip() -> HashSet { let mut skip_dirs = HashSet::from( [ "node_modules", "target", "build", "dist", ".next", ".nuxt", ".cache", ".npm", ".yarn", ".pnpm-store", "Library", "AppData", "Applications", ] .map(String::from), ); [ dirs::executable_dir(), dirs::data_dir(), dirs::download_dir(), dirs::picture_dir(), dirs::video_dir(), dirs::audio_dir(), ] .into_iter() .flatten() .filter_map(|path| path.file_name()?.to_str().map(String::from)) .for_each(|name| { skip_dirs.insert(name); }); skip_dirs } #[cfg_attr(feature = "qa-mode", allow(unused_variables))] pub async fn list_git_repos( &self, path: Option, timeout_ms: u64, hard_timeout_ms: u64, max_depth: Option, ) -> Result, FilesystemError> { #[cfg(feature = "qa-mode")] { tracing::info!("QA mode: returning hardcoded QA repos instead of scanning filesystem"); super::qa_repos::get_qa_repos() } #[cfg(not(feature = "qa-mode"))] { let base_path = path .map(PathBuf::from) .unwrap_or_else(Self::get_home_directory); Self::verify_directory(&base_path)?; self.list_git_repos_with_timeout( vec![base_path], timeout_ms, hard_timeout_ms, max_depth, ) .await } } #[cfg(not(feature = "qa-mode"))] async fn list_git_repos_with_timeout( &self, paths: Vec, timeout_ms: u64, hard_timeout_ms: u64, max_depth: Option, ) -> Result, FilesystemError> { let cancel_token = CancellationToken::new(); let cancel_after_delay = cancel_token.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)).await; cancel_after_delay.cancel(); }); let service = self.clone(); let cancel_for_scan = cancel_token.clone(); let mut scan_handle = tokio::spawn(async move { service .list_git_repos_inner(paths, max_depth, Some(&cancel_for_scan)) .await }); let hard_timeout = tokio::time::sleep(std::time::Duration::from_millis(hard_timeout_ms)); tokio::pin!(hard_timeout); tokio::select! { res = &mut scan_handle => { match res { Ok(Ok(repos)) => Ok(repos), Ok(Err(err)) => Err(err), Err(join_err) => Err(FilesystemError::Io( std::io::Error::other(join_err.to_string()))) } } _ = &mut hard_timeout => { scan_handle.abort(); tracing::warn!("list_git_repos_with_timeout: hard timeout reached after {}ms", hard_timeout_ms); Err(FilesystemError::Io(std::io::Error::new( std::io::ErrorKind::TimedOut, "Operation forcibly terminated due to hard timeout", ))) } } } #[cfg_attr(feature = "qa-mode", allow(unused_variables))] pub async fn list_common_git_repos( &self, timeout_ms: u64, hard_timeout_ms: u64, max_depth: Option, ) -> Result, FilesystemError> { #[cfg(feature = "qa-mode")] { tracing::info!( "QA mode: returning hardcoded QA repos instead of scanning common directories" ); super::qa_repos::get_qa_repos() } #[cfg(not(feature = "qa-mode"))] { let search_strings = ["repos", "dev", "work", "code", "projects"]; let home_dir = Self::get_home_directory(); let mut paths: Vec = search_strings .iter() .map(|s| home_dir.join(s)) .filter(|p| p.exists() && p.is_dir()) .collect(); paths.insert(0, home_dir); if let Some(cwd) = std::env::current_dir().ok() && cwd.exists() && cwd.is_dir() { paths.insert(0, cwd); } self.list_git_repos_with_timeout(paths, timeout_ms, hard_timeout_ms, max_depth) .await } } #[cfg(not(feature = "qa-mode"))] async fn list_git_repos_inner( &self, path: Vec, max_depth: Option, cancel: Option<&CancellationToken>, ) -> Result, FilesystemError> { let base_dir = match path.first() { Some(dir) => dir, None => return Ok(vec![]), }; let skip_dirs = Self::get_directories_to_skip(); let vibe_kanban_temp_dir = utils::path::get_vibe_kanban_temp_dir(); let mut walker_builder = WalkBuilder::new(base_dir); walker_builder .follow_links(false) .hidden(true) // true to skip hidden files .git_ignore(true) .filter_entry({ let cancel = cancel.cloned(); move |entry| { if let Some(token) = cancel.as_ref() && token.is_cancelled() { tracing::debug!("Cancellation token triggered"); return false; } let path = entry.path(); if !path.is_dir() { return false; } // Skip vibe-kanban temp directory and all subdirectories // Normalize to handle macOS /private/var vs /var aliasing if utils::path::normalize_macos_private_alias(path) .starts_with(&vibe_kanban_temp_dir) { return false; } // Skip common non-git folders if let Some(name) = path.file_name().and_then(|n| n.to_str()) && skip_dirs.contains(name) { return false; } true } }) .max_depth(max_depth) .git_exclude(true); for p in path.iter().skip(1) { walker_builder.add(p); } let mut seen_dirs = HashSet::new(); let mut git_repos: Vec = walker_builder .build() .filter_map(|entry| { let entry = entry.ok()?; if seen_dirs.contains(entry.path()) { return None; } seen_dirs.insert(entry.path().to_owned()); let name = entry.file_name().to_str()?; if !entry.path().join(".git").exists() { return None; } let last_modified = entry .metadata() .ok() .and_then(|m| m.modified().ok()) .map(|t| t.elapsed().unwrap_or_default().as_secs()); Some(DirectoryEntry { name: name.to_string(), path: entry.into_path(), is_directory: true, is_git_repo: true, last_modified, }) }) .collect(); git_repos.sort_by_key(|entry| entry.last_modified.unwrap_or(0)); Ok(git_repos) } fn get_home_directory() -> PathBuf { dirs::home_dir() .or_else(dirs::desktop_dir) .or_else(dirs::document_dir) .unwrap_or_else(|| { if cfg!(windows) { std::env::var("USERPROFILE") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("C:\\")) } else { PathBuf::from("/") } }) } fn verify_directory(path: &Path) -> Result<(), FilesystemError> { if !path.exists() { return Err(FilesystemError::DirectoryDoesNotExist); } if !path.is_dir() { return Err(FilesystemError::PathIsNotDirectory); } Ok(()) } pub async fn list_directory( &self, path: Option, ) -> Result { let path = path .map(PathBuf::from) .unwrap_or_else(Self::get_home_directory); Self::verify_directory(&path)?; let entries = fs::read_dir(&path)?; let mut directory_entries = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let metadata = entry.metadata().ok(); if let Some(name) = path.file_name().and_then(|n| n.to_str()) { // Skip hidden files/directories if name.starts_with('.') && name != ".." { continue; } let is_directory = metadata.is_some_and(|m| m.is_dir()); let is_git_repo = if is_directory { path.join(".git").exists() } else { false }; directory_entries.push(DirectoryEntry { name: name.to_string(), path, is_directory, is_git_repo, last_modified: None, }); } } // Sort: directories first, then files, both alphabetically directory_entries.sort_by(|a, b| match (a.is_directory, b.is_directory) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); Ok(DirectoryListResponse { entries: directory_entries, current_path: path.to_string_lossy().to_string(), }) } } ================================================ FILE: crates/services/src/services/filesystem_watcher.rs ================================================ use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; use futures::{ SinkExt, StreamExt, channel::mpsc::{Receiver, channel}, }; use ignore::{ WalkBuilder, gitignore::{Gitignore, GitignoreBuilder}, }; use notify::{ RecommendedWatcher, RecursiveMode, event::{EventKind, ModifyKind, RenameMode}, }; use notify_debouncer_full::{ DebounceEventResult, DebouncedEvent, Debouncer, RecommendedCache, new_debouncer, }; use thiserror::Error; use utils::path::ALWAYS_SKIP_DIRS; pub type WatcherComponents = ( Arc>>, Receiver, PathBuf, ); #[derive(Debug, Error)] pub enum FilesystemWatcherError { #[error(transparent)] Notify(#[from] notify::Error), #[error(transparent)] Ignore(#[from] ignore::Error), #[error(transparent)] IoError(#[from] std::io::Error), #[error("Failed to build gitignore: {0}")] GitignoreBuilder(String), #[error("Invalid path: {0}")] InvalidPath(String), } fn canonicalize_lossy(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } fn should_skip_dir(name: &str) -> bool { ALWAYS_SKIP_DIRS.contains(&name) } /// Check if the platform supports efficient native recursive watching. /// macOS (FSEvents) and Windows (ReadDirectoryChangesW) support recursive watching natively. /// Linux (inotify) does not - it requires a watch descriptor per directory. fn platform_supports_native_recursive() -> bool { cfg!(target_os = "macos") || cfg!(target_os = "windows") } fn build_gitignore_set(root: &Path) -> Result { let mut builder = GitignoreBuilder::new(root); // Walk once to collect all .gitignore files under root // Use git_ignore(true) to avoid walking into gitignored directories WalkBuilder::new(root) .follow_links(false) .hidden(false) // we *want* to see .gitignore .git_ignore(true) // Respect gitignore to skip heavy directories .filter_entry(|entry| { let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); // Skip .git directory if is_dir && let Some(name) = entry.file_name().to_str() && should_skip_dir(name) { return false; } // only recurse into directories and .gitignore files is_dir || entry .file_name() .to_str() .is_some_and(|name| name == ".gitignore") }) .build() .try_for_each(|result| { // everything that is not a directory and is named .gitignore match result { Ok(dir_entry) => { if !dir_entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { builder.add(dir_entry.path()); } Ok(()) } Err(err) if err.io_error().is_some_and(|io_err| { io_err.kind() == std::io::ErrorKind::PermissionDenied }) => { // Skip entries we don't have permission to read tracing::warn!("Permission denied reading path: {}", err); Ok(()) } Err(e) => Err(FilesystemWatcherError::Ignore(e)), } })?; // Optionally include repo-local excludes let info_exclude = root.join(".git/info/exclude"); if info_exclude.exists() { builder.add(info_exclude); } Ok(builder.build()?) } fn path_allowed(path: &Path, gi: &Gitignore, canonical_root: &Path) -> bool { let canonical_path = canonicalize_lossy(path); // Convert absolute path to relative path from the gitignore root let relative_path = match canonical_path.strip_prefix(canonical_root) { Ok(rel_path) => rel_path, Err(_) => { // Path is outside the watched root, don't ignore it return true; } }; // Check if path is inside any of the always-skip directories if let Some(parent) = relative_path.parent() { for component in parent.components() { if let std::path::Component::Normal(name) = component && let Some(name_str) = name.to_str() && should_skip_dir(name_str) { return false; } } } let is_dir = if let Ok(metadata) = std::fs::metadata(&canonical_path) { metadata.is_dir() } else { // File may already be gone (e.g., remove event). Fall back to the // old extension heuristic so directory-only rules still match. // FIXME: capture file-type information earlier (e.g., when we add // watches) so we don't have to guess after the fact. relative_path.extension().is_none() }; let matched = gi.matched_path_or_any_parents(relative_path, is_dir); !matched.is_ignore() } fn debounced_should_forward(event: &DebouncedEvent, gi: &Gitignore, canonical_root: &Path) -> bool { // DebouncedEvent is a struct that wraps the underlying notify::Event if event.kind.is_access() { // Ignore access events return false; } // We can check its paths field to determine if the event should be forwarded event .paths .iter() .all(|path| path_allowed(path, gi, canonical_root)) } /// Represents a directory to watch with its recursive mode. #[derive(Debug, Clone)] struct WatchTarget { path: PathBuf, recursive: RecursiveMode, } #[derive(Default)] struct WatchedDirs { all: HashSet, recursive: HashSet, } impl WatchedDirs { fn contains(&self, path: &Path) -> bool { self.all.contains(path) } fn has_recursive_cover(&self, path: &Path) -> bool { self.recursive .iter() .any(|ancestor| ancestor != path && path.starts_with(ancestor)) } fn insert(&mut self, path: PathBuf, mode: RecursiveMode) { if matches!(mode, RecursiveMode::Recursive) { self.recursive.insert(path.clone()); } else { self.recursive.remove(&path); } self.all.insert(path); } fn remove_dir_and_children(&mut self, prefix: &Path, f: F) where F: FnMut(&Path), { self.remove_with_prefix(prefix, true, f); } fn remove_children_only(&mut self, prefix: &Path, f: F) where F: FnMut(&Path), { self.remove_with_prefix(prefix, false, f); } fn remove_with_prefix(&mut self, prefix: &Path, include_prefix: bool, mut f: F) where F: FnMut(&Path), { let to_remove: Vec = self .all .iter() .filter(|path| path.starts_with(prefix) && (include_prefix || *path != prefix)) .cloned() .collect(); for path in to_remove { self.all.remove(&path); self.recursive.remove(&path); f(&path); } } } /// Check if a directory or any of its descendants has gitignored directories. /// Used on macOS/Windows to determine if we can watch recursively. /// /// This checks recursively to ensure we don't use Recursive mode on a directory /// that has gitignored descendants (e.g., packages/app1/node_modules). fn has_ignored_descendants( dir: &Path, gi: &Gitignore, canonical_root: &Path, allowed_dirs: &std::collections::HashSet, cache: &mut HashMap, ) -> bool { let key = dir.to_path_buf(); if let Some(&cached) = cache.get(&key) { return cached; } // Read immediate children let result = (|| { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if !file_type.is_dir() { continue; } let path = entry.path(); // Check if this subdirectory should be skipped if let Some(name) = path.file_name().and_then(|n| n.to_str()) && should_skip_dir(name) { return true; } // If it's not in allowed_dirs, it means WalkBuilder skipped it (gitignored) if !allowed_dirs.contains(&path) && !path_allowed(&path, gi, canonical_root) { return true; } if has_ignored_descendants(&path, gi, canonical_root, allowed_dirs, cache) { return true; } } false })(); cache.insert(key, result); result } /// Collect directories to watch, respecting gitignore and excluding .git. /// On macOS/Windows, use recursive mode for directories without ignored subdirectories. /// On Linux, use non-recursive mode for all directories. fn collect_watch_directories(root: &Path, gi: &Gitignore) -> Vec { let use_recursive = platform_supports_native_recursive(); let mut allowed_dirs: Vec = WalkBuilder::new(root) .follow_links(false) .hidden(false) .git_ignore(true) // Respect gitignore to skip node_modules, target, etc. .filter_entry(|entry| { let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); if !is_dir { return false; } if let Some(name) = entry.file_name().to_str() && should_skip_dir(name) { return false; } true }) .build() .filter_map(|result| result.ok()) .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) .map(|entry| entry.into_path()) .collect(); allowed_dirs.sort(); let allowed_dirs_set: HashSet = allowed_dirs.iter().cloned().collect(); let mut ignored_cache = HashMap::new(); let mut ancestor_stack: Vec<(PathBuf, bool)> = Vec::new(); allowed_dirs .into_iter() .filter_map(|path| { while ancestor_stack .last() .is_some_and(|(ancestor, _)| !path.starts_with(ancestor)) { ancestor_stack.pop(); } if ancestor_stack .last() .is_some_and(|(_, is_recursive)| *is_recursive) { return None; } let recursive_mode = if use_recursive { if has_ignored_descendants(&path, gi, root, &allowed_dirs_set, &mut ignored_cache) { RecursiveMode::NonRecursive } else { RecursiveMode::Recursive } } else { RecursiveMode::NonRecursive }; let is_recursive = matches!(recursive_mode, RecursiveMode::Recursive); ancestor_stack.push((path.clone(), is_recursive)); Some(WatchTarget { path, recursive: recursive_mode, }) }) .collect() } /// Helper to determine watch mode for a directory (used for dynamically added directories). /// This does a simple check of immediate children only, since we don't have the full /// allowed_dirs set at runtime. fn determine_watch_mode(path: &Path, gi: &Gitignore, canonical_root: &Path) -> RecursiveMode { if !platform_supports_native_recursive() { return RecursiveMode::NonRecursive; } let Ok(entries) = std::fs::read_dir(path) else { return RecursiveMode::Recursive; }; for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if !file_type.is_dir() { continue; } let child_path = entry.path(); if let Some(name) = child_path.file_name().and_then(|n| n.to_str()) && should_skip_dir(name) { return RecursiveMode::NonRecursive; } if !path_allowed(&child_path, gi, canonical_root) { return RecursiveMode::NonRecursive; } } RecursiveMode::Recursive } /// Add a watch for a newly created directory fn add_directory_watch( debouncer: &mut Debouncer, watched_dirs: &mut WatchedDirs, dir_path: &Path, gi: &Gitignore, canonical_root: &Path, ) { let canonical_dir = canonicalize_lossy(dir_path); if !path_allowed(&canonical_dir, gi, canonical_root) { return; } if watched_dirs.contains(&canonical_dir) || watched_dirs.has_recursive_cover(&canonical_dir) { return; } let mode = determine_watch_mode(&canonical_dir, gi, canonical_root); if let Err(e) = debouncer.watch(&canonical_dir, mode) { tracing::warn!("Failed to watch new directory {:?}: {}", canonical_dir, e); } else { if matches!(mode, RecursiveMode::Recursive) { watched_dirs.remove_children_only(&canonical_dir, |child| { if let Err(err) = debouncer.unwatch(child) { tracing::warn!("Could not unwatch covered directory {:?}: {}", child, err); } }); } watched_dirs.insert(canonical_dir, mode); } } /// Remove a watch for a deleted directory fn remove_directory_watch( debouncer: &mut Debouncer, watched_dirs: &mut WatchedDirs, dir_path: &Path, ) { let canonical_dir = canonicalize_lossy(dir_path); watched_dirs.remove_dir_and_children(&canonical_dir, |path| { if let Err(e) = debouncer.unwatch(path) { tracing::warn!("Could not unwatch deleted directory {:?}: {}", path, e); } }); } pub fn async_watcher(root: PathBuf) -> Result { let canonical_root = canonicalize_lossy(&root); let gi_set = Arc::new(build_gitignore_set(&canonical_root)?); // NOTE: changes to .gitignore aren’t picked up until the watcher is rebuilt. // Recomputing on every change would require rebuilding the full watcher fleet. let (mut raw_tx, mut raw_rx) = channel::(64); let (mut filtered_tx, filtered_rx) = channel::(64); let gi_clone = gi_set.clone(); let root_for_task = canonical_root.clone(); let debouncer_unwrapped = new_debouncer( Duration::from_millis(200), None, move |res: DebounceEventResult| { futures::executor::block_on(async { raw_tx.send(res).await.ok(); }); }, )?; let debouncer = Arc::new(Mutex::new(debouncer_unwrapped)); let debouncer_for_init = debouncer.clone(); let debouncer_for_task = Arc::downgrade(&debouncer); let watched_dirs: Arc> = Arc::new(Mutex::new(WatchedDirs::default())); let watched_dirs_for_task = watched_dirs.clone(); let watch_targets = collect_watch_directories(&canonical_root, &gi_set); { let mut debouncer_guard = debouncer_for_init.lock().unwrap(); let mut watched = watched_dirs.lock().unwrap(); for target in &watch_targets { if let Err(e) = debouncer_guard.watch(&target.path, target.recursive) { tracing::warn!("Failed to watch {:?}: {}", target.path, e); } else { watched.insert(target.path.clone(), target.recursive); } } } std::thread::spawn(move || { while let Some(result) = futures::executor::block_on(async { raw_rx.next().await }) { let Some(debouncer_arc) = debouncer_for_task.upgrade() else { break; }; match result { Ok(events) => { let mut debouncer_guard = debouncer_arc.lock().unwrap(); let mut watched = watched_dirs_for_task.lock().unwrap(); for event in &events { if event.kind.is_create() { for path in &event.paths { if path.is_dir() { add_directory_watch( &mut debouncer_guard, &mut watched, path, &gi_clone, &root_for_task, ); } } } else if event.kind.is_remove() { for path in &event.paths { remove_directory_watch(&mut debouncer_guard, &mut watched, path); } } else if let EventKind::Modify(ModifyKind::Name(mode)) = &event.kind { match mode { RenameMode::From => { for path in &event.paths { remove_directory_watch( &mut debouncer_guard, &mut watched, path, ); } } RenameMode::To => { for path in &event.paths { if path.is_dir() { add_directory_watch( &mut debouncer_guard, &mut watched, path, &gi_clone, &root_for_task, ); } } } RenameMode::Both => { if let Some((from, rest)) = event.paths.split_first() { remove_directory_watch( &mut debouncer_guard, &mut watched, from, ); if let Some(to) = rest.last() && to.is_dir() { add_directory_watch( &mut debouncer_guard, &mut watched, to, &gi_clone, &root_for_task, ); } } } RenameMode::Any | RenameMode::Other => { for path in &event.paths { remove_directory_watch( &mut debouncer_guard, &mut watched, path, ); } for path in &event.paths { if path.is_dir() { add_directory_watch( &mut debouncer_guard, &mut watched, path, &gi_clone, &root_for_task, ); } } } } } } drop(debouncer_guard); drop(watched); let filtered_events: Vec = events .into_iter() .filter(|ev| debounced_should_forward(ev, &gi_set, &root_for_task)) .collect(); if !filtered_events.is_empty() { futures::executor::block_on(async { filtered_tx.send(Ok(filtered_events)).await.ok(); }); } } Err(errors) => { futures::executor::block_on(async { filtered_tx.send(Err(errors)).await.ok(); }); } } } }); Ok((debouncer, filtered_rx, canonical_root)) } ================================================ FILE: crates/services/src/services/migration/error.rs ================================================ use thiserror::Error; use crate::services::remote_client::RemoteClientError; #[derive(Debug, Error)] pub enum MigrationError { #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] MigrationState(#[from] db::models::migration_state::MigrationStateError), #[error(transparent)] Workspace(#[from] db::models::workspace::WorkspaceError), #[error(transparent)] RemoteClient(#[from] RemoteClientError), #[error("not authenticated - please log in first")] NotAuthenticated, #[error("organization not found for user")] OrganizationNotFound, #[error("entity not found: {entity_type} with id {id}")] EntityNotFound { entity_type: String, id: String }, #[error("migration already in progress")] MigrationInProgress, #[error("status mapping failed: unknown status '{0}'")] StatusMappingFailed(String), #[error("broken reference chain: {0}")] BrokenReferenceChain(String), #[error("remote error: {0}")] RemoteError(String), } ================================================ FILE: crates/services/src/services/migration/mod.rs ================================================ mod error; mod types; use std::collections::HashSet; use api_types::{ BulkMigrateRequest, BulkMigrateResponse, MigrateIssueRequest, MigrateProjectRequest, MigratePullRequestRequest, MigrateWorkspaceRequest, }; use db::models::{ merge::{Merge, MergeStatus, PrMerge}, migration_state::{CreateMigrationState, EntityType, MigrationState, MigrationStatus}, project::Project, task::{Task, TaskStatus}, workspace::Workspace, }; pub use error::MigrationError; use sqlx::SqlitePool; use tracing::info; pub use types::*; use uuid::Uuid; use crate::services::remote_client::RemoteClient; const BATCH_SIZE: usize = 100; pub struct MigrationService { sqlite_pool: SqlitePool, remote_client: RemoteClient, } impl MigrationService { pub fn new(sqlite_pool: SqlitePool, remote_client: RemoteClient) -> Self { Self { sqlite_pool, remote_client, } } pub async fn run_migration( &self, organization_id: Uuid, project_ids: HashSet, ) -> Result { let mut report = MigrationReport::default(); info!( "Starting migration to organization {} for {} projects", organization_id, project_ids.len() ); info!("Phase 1: Migrating projects..."); self.migrate_projects(organization_id, &project_ids, &mut report) .await?; info!("Phase 2: Migrating tasks to issues..."); self.migrate_tasks(&project_ids, &mut report).await?; info!("Phase 3: Migrating PR merges to pull requests..."); self.migrate_pr_merges(&project_ids, &mut report).await?; info!("Phase 4: Migrating workspaces..."); self.migrate_workspaces(&project_ids, &mut report).await?; info!( "Migration complete. Projects: {}/{}, Tasks: {}/{}, PRs: {}/{}, Workspaces: {}/{}", report.projects.migrated, report.projects.total, report.tasks.migrated, report.tasks.total, report.pr_merges.migrated, report.pr_merges.total, report.workspaces.migrated, report.workspaces.total ); Ok(report) } pub async fn get_status( &self, project_ids: &HashSet, ) -> Result { let projects = MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Project).await?; let tasks = MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Task).await?; let pr_merges = MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::PrMerge).await?; let workspaces = MigrationState::find_by_entity_type(&self.sqlite_pool, EntityType::Workspace).await?; let projects: Vec<_> = projects .into_iter() .filter(|s| project_ids.contains(&s.local_id)) .collect(); Ok(MigrationReport { projects: Self::entity_report_from_states(&projects), tasks: Self::entity_report_from_states(&tasks), pr_merges: Self::entity_report_from_states(&pr_merges), workspaces: Self::entity_report_from_states(&workspaces), warnings: vec![], }) } pub async fn resume_migration( &self, organization_id: Uuid, project_ids: HashSet, ) -> Result { MigrationState::reset_failed(&self.sqlite_pool).await?; self.run_migration(organization_id, project_ids).await } async fn migrate_projects( &self, organization_id: Uuid, project_ids: &HashSet, report: &mut MigrationReport, ) -> Result<(), MigrationError> { let all_projects = Project::find_all(&self.sqlite_pool).await?; let projects: Vec<_> = all_projects .into_iter() .filter(|p| project_ids.contains(&p.id)) .collect(); report.projects.total = projects.len(); let mut pending_projects = Vec::new(); for project in &projects { if let Some(existing) = MigrationState::find_by_entity(&self.sqlite_pool, EntityType::Project, project.id) .await? && existing.status == MigrationStatus::Migrated { report.projects.skipped += 1; continue; } pending_projects.push(project.clone()); } for chunk in pending_projects.chunks(BATCH_SIZE) { self.migrate_project_batch(organization_id, chunk, report) .await?; } Ok(()) } async fn migrate_project_batch( &self, organization_id: Uuid, projects: &[Project], report: &mut MigrationReport, ) -> Result<(), MigrationError> { for project in projects { MigrationState::upsert( &self.sqlite_pool, &CreateMigrationState { entity_type: EntityType::Project, local_id: project.id, }, ) .await?; } let requests: Vec = projects .iter() .enumerate() .map(|(i, p)| MigrateProjectRequest { organization_id, name: p.name.clone(), color: generate_hsl_color(i), created_at: p.created_at, }) .collect(); let response: BulkMigrateResponse = self .remote_client .post_authed( "/v1/migration/projects", Some(&BulkMigrateRequest { items: requests }), ) .await?; for (project, remote_id) in projects.iter().zip(response.ids.iter()) { MigrationState::mark_migrated( &self.sqlite_pool, EntityType::Project, project.id, *remote_id, ) .await?; Project::set_remote_project_id(&self.sqlite_pool, project.id, Some(*remote_id)).await?; report.projects.migrated += 1; } Ok(()) } async fn migrate_tasks( &self, project_ids: &HashSet, report: &mut MigrationReport, ) -> Result<(), MigrationError> { let all_tasks = Task::find_all(&self.sqlite_pool).await?; let tasks: Vec<_> = all_tasks .into_iter() .filter(|t| project_ids.contains(&t.project_id)) .collect(); report.tasks.total = tasks.len(); let mut pending_tasks = Vec::new(); for task in &tasks { if let Some(existing) = MigrationState::find_by_entity(&self.sqlite_pool, EntityType::Task, task.id).await? && existing.status == MigrationStatus::Migrated { report.tasks.skipped += 1; continue; } pending_tasks.push(task.clone()); } for chunk in pending_tasks.chunks(BATCH_SIZE) { self.migrate_task_batch(chunk, report).await?; } Ok(()) } async fn migrate_task_batch( &self, tasks: &[Task], report: &mut MigrationReport, ) -> Result<(), MigrationError> { for task in tasks { MigrationState::upsert( &self.sqlite_pool, &CreateMigrationState { entity_type: EntityType::Task, local_id: task.id, }, ) .await?; } let mut requests = Vec::new(); let mut request_task_ids = Vec::new(); for task in tasks { let remote_project_id = MigrationState::get_remote_id( &self.sqlite_pool, EntityType::Project, task.project_id, ) .await?; match remote_project_id { Some(project_id) => { requests.push(MigrateIssueRequest { project_id, status_name: map_task_status(&task.status), title: task.title.chars().take(255).collect(), description: task.description.clone(), created_at: task.created_at, }); request_task_ids.push(task.id); } None => { let error_msg = format!("Project {} not migrated", task.project_id); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::Task, task.id, &error_msg, ) .await?; report.tasks.skipped += 1; report .warnings .push(format!("Skipped task {}: {}", task.id, error_msg)); } } } if requests.is_empty() { return Ok(()); } let response: BulkMigrateResponse = self .remote_client .post_authed( "/v1/migration/issues", Some(&BulkMigrateRequest { items: requests }), ) .await?; for (task_id, remote_id) in request_task_ids.iter().zip(response.ids.iter()) { MigrationState::mark_migrated( &self.sqlite_pool, EntityType::Task, *task_id, *remote_id, ) .await?; report.tasks.migrated += 1; } Ok(()) } async fn migrate_pr_merges( &self, project_ids: &HashSet, report: &mut MigrationReport, ) -> Result<(), MigrationError> { let all_pr_merges = Merge::find_all_pr(&self.sqlite_pool).await?; let mut pr_merges = Vec::new(); for pr_merge in all_pr_merges { if let Some(workspace) = Workspace::find_by_id(&self.sqlite_pool, pr_merge.workspace_id).await? && let Some(task_id) = workspace.task_id && let Some(task) = Task::find_by_id(&self.sqlite_pool, task_id).await? && project_ids.contains(&task.project_id) { pr_merges.push(pr_merge); } } report.pr_merges.total = pr_merges.len(); let mut pending_merges = Vec::new(); for pr_merge in &pr_merges { if let Some(existing) = MigrationState::find_by_entity(&self.sqlite_pool, EntityType::PrMerge, pr_merge.id) .await? && existing.status == MigrationStatus::Migrated { report.pr_merges.skipped += 1; continue; } pending_merges.push(pr_merge.clone()); } for chunk in pending_merges.chunks(BATCH_SIZE) { self.migrate_pr_merge_batch(chunk, report).await?; } Ok(()) } async fn migrate_pr_merge_batch( &self, pr_merges: &[PrMerge], report: &mut MigrationReport, ) -> Result<(), MigrationError> { for pr_merge in pr_merges { MigrationState::upsert( &self.sqlite_pool, &CreateMigrationState { entity_type: EntityType::PrMerge, local_id: pr_merge.id, }, ) .await?; } let mut requests = Vec::new(); let mut request_merge_ids = Vec::new(); for pr_merge in pr_merges { let workspace = Workspace::find_by_id(&self.sqlite_pool, pr_merge.workspace_id).await?; let issue_id = match workspace.and_then(|ws| ws.task_id) { Some(task_id) => { MigrationState::get_remote_id(&self.sqlite_pool, EntityType::Task, task_id) .await? } None => None, }; match issue_id { Some(remote_issue_id) => { requests.push(MigratePullRequestRequest { url: pr_merge.pr_info.url.clone(), number: pr_merge.pr_info.number as i32, status: map_merge_status(&pr_merge.pr_info.status), merged_at: pr_merge.pr_info.merged_at, merge_commit_sha: pr_merge.pr_info.merge_commit_sha.clone(), target_branch_name: pr_merge.target_branch_name.clone(), issue_id: remote_issue_id, }); request_merge_ids.push(pr_merge.id); } None => { let error_msg = format!( "Cannot resolve issue for PR merge {} (workspace: {})", pr_merge.id, pr_merge.workspace_id ); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::PrMerge, pr_merge.id, &error_msg, ) .await?; report.pr_merges.skipped += 1; report .warnings .push(format!("Skipped PR {}: {}", pr_merge.id, error_msg)); } } } if requests.is_empty() { return Ok(()); } let response: BulkMigrateResponse = self .remote_client .post_authed( "/v1/migration/pull_requests", Some(&BulkMigrateRequest { items: requests }), ) .await?; for (merge_id, remote_id) in request_merge_ids.iter().zip(response.ids.iter()) { MigrationState::mark_migrated( &self.sqlite_pool, EntityType::PrMerge, *merge_id, *remote_id, ) .await?; report.pr_merges.migrated += 1; } Ok(()) } async fn migrate_workspaces( &self, project_ids: &HashSet, report: &mut MigrationReport, ) -> Result<(), MigrationError> { let all_workspaces = Workspace::fetch_all(&self.sqlite_pool).await?; let mut workspaces = Vec::new(); for workspace in all_workspaces { if let Some(task_id) = workspace.task_id && let Some(task) = Task::find_by_id(&self.sqlite_pool, task_id).await? && project_ids.contains(&task.project_id) { workspaces.push(workspace); } } report.workspaces.total = workspaces.len(); let mut pending_workspaces = Vec::new(); for workspace in &workspaces { if let Some(existing) = MigrationState::find_by_entity( &self.sqlite_pool, EntityType::Workspace, workspace.id, ) .await? && existing.status == MigrationStatus::Migrated { report.workspaces.skipped += 1; continue; } pending_workspaces.push(workspace.clone()); } for chunk in pending_workspaces.chunks(BATCH_SIZE) { self.migrate_workspace_batch(chunk, report).await?; } Ok(()) } async fn migrate_workspace_batch( &self, workspaces: &[Workspace], report: &mut MigrationReport, ) -> Result<(), MigrationError> { for workspace in workspaces { MigrationState::upsert( &self.sqlite_pool, &CreateMigrationState { entity_type: EntityType::Workspace, local_id: workspace.id, }, ) .await?; } let mut requests = Vec::new(); let mut request_workspace_ids = Vec::new(); for workspace in workspaces { let task_id = match workspace.task_id { Some(id) => id, None => { let error_msg = "Workspace has no task_id".to_string(); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::Workspace, workspace.id, &error_msg, ) .await?; report.workspaces.skipped += 1; report .warnings .push(format!("Skipped workspace {}: {}", workspace.id, error_msg)); continue; } }; let task = match Task::find_by_id(&self.sqlite_pool, task_id).await? { Some(t) => t, None => { let error_msg = format!("Task {} not found for workspace", task_id); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::Workspace, workspace.id, &error_msg, ) .await?; report.workspaces.skipped += 1; report .warnings .push(format!("Skipped workspace {}: {}", workspace.id, error_msg)); continue; } }; let remote_project_id = MigrationState::get_remote_id( &self.sqlite_pool, EntityType::Project, task.project_id, ) .await?; let remote_project_id = match remote_project_id { Some(id) => id, None => { let error_msg = format!("Project {} not migrated", task.project_id); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::Workspace, workspace.id, &error_msg, ) .await?; report.workspaces.skipped += 1; report .warnings .push(format!("Skipped workspace {}: {}", workspace.id, error_msg)); continue; } }; let remote_issue_id = MigrationState::get_remote_id(&self.sqlite_pool, EntityType::Task, task_id).await?; if remote_issue_id.is_none() { let error_msg = format!("Task {} not migrated", task_id); MigrationState::mark_skipped( &self.sqlite_pool, EntityType::Workspace, workspace.id, &error_msg, ) .await?; report.workspaces.skipped += 1; report .warnings .push(format!("Skipped workspace {}: {}", workspace.id, error_msg)); continue; } requests.push(MigrateWorkspaceRequest { project_id: remote_project_id, issue_id: remote_issue_id, local_workspace_id: workspace.id, archived: workspace.archived, created_at: workspace.created_at, }); request_workspace_ids.push(workspace.id); } if requests.is_empty() { return Ok(()); } let response: BulkMigrateResponse = self .remote_client .post_authed( "/v1/migration/workspaces", Some(&BulkMigrateRequest { items: requests }), ) .await?; for (workspace_id, remote_id) in request_workspace_ids.iter().zip(response.ids.iter()) { MigrationState::mark_migrated( &self.sqlite_pool, EntityType::Workspace, *workspace_id, *remote_id, ) .await?; report.workspaces.migrated += 1; } Ok(()) } fn entity_report_from_states(states: &[MigrationState]) -> EntityReport { let mut report = EntityReport { total: states.len(), ..Default::default() }; for state in states { match state.status { MigrationStatus::Migrated => report.migrated += 1, MigrationStatus::Failed => { report.failed += 1; if let Some(ref msg) = state.error_message { report.errors.push(EntityError { local_id: state.local_id, error: msg.clone(), }); } } MigrationStatus::Skipped => report.skipped += 1, MigrationStatus::Pending => {} } } report } } fn map_task_status(status: &TaskStatus) -> String { match status { TaskStatus::Todo => "To do".to_string(), TaskStatus::InProgress => "In progress".to_string(), TaskStatus::InReview => "In review".to_string(), TaskStatus::Done => "Done".to_string(), TaskStatus::Cancelled => "Cancelled".to_string(), } } fn map_merge_status(status: &MergeStatus) -> String { match status { MergeStatus::Open => "open".to_string(), MergeStatus::Merged => "merged".to_string(), MergeStatus::Closed => "closed".to_string(), MergeStatus::Unknown => "open".to_string(), } } fn generate_hsl_color(index: usize) -> String { let hues = [217, 142, 38, 258, 0, 180, 300, 60]; let h = hues[index % hues.len()]; let s = 70 + (index / hues.len()) % 20; let l = 50 + (index / hues.len()) % 15; format!("{} {}% {}%", h, s, l) } ================================================ FILE: crates/services/src/services/migration/types.rs ================================================ use std::collections::HashSet; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct MigrationRequest { pub organization_id: Uuid, /// List of local project IDs to migrate. pub project_ids: Vec, } impl MigrationRequest { /// Returns the set of project IDs to migrate. pub fn project_id_set(&self) -> HashSet { self.project_ids.iter().copied().collect() } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct MigrationResponse { pub report: MigrationReport, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] pub struct MigrationReport { pub projects: EntityReport, pub tasks: EntityReport, pub pr_merges: EntityReport, pub workspaces: EntityReport, pub warnings: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] pub struct EntityReport { pub total: usize, pub migrated: usize, pub failed: usize, pub skipped: usize, pub errors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct EntityError { pub local_id: Uuid, pub error: String, } ================================================ FILE: crates/services/src/services/mod.rs ================================================ pub mod analytics; pub mod approvals; pub mod auth; pub mod config; pub mod container; pub mod diff_stream; pub mod events; pub mod execution_process; pub mod file; pub mod file_ranker; pub mod file_search; pub mod filesystem; pub mod filesystem_watcher; pub mod migration; pub mod notification; pub mod oauth_credentials; pub mod pr_monitor; #[cfg(feature = "qa-mode")] pub mod qa_repos; pub mod queued_message; pub mod remote_client; pub mod remote_sync; pub mod repo; ================================================ FILE: crates/services/src/services/notification.rs ================================================ use std::sync::{Arc, OnceLock}; use async_trait::async_trait; use tokio::sync::RwLock; use utils::{self, command_ext::NoWindowExt}; use uuid::Uuid; use crate::services::config::{Config, SoundFile}; /// Trait for sending push notifications. Implementations can use /// platform-specific OS commands, Tauri's notification plugin, etc. #[async_trait] pub trait PushNotifier: Send + Sync + 'static { async fn send(&self, title: &str, message: &str, workspace_id: Option); } /// Global push notifier set before server startup (e.g., by the Tauri app). /// Falls back to `DefaultPushNotifier` if not set. static GLOBAL_PUSH_NOTIFIER: OnceLock> = OnceLock::new(); /// Register a custom push notifier globally. Must be called before the server /// starts (i.e., before `LocalDeployment::new()`). Typically called from the /// Tauri app to inject a `TauriNotifier` that uses the native notification API. pub fn set_global_push_notifier(notifier: Arc) { let _ = GLOBAL_PUSH_NOTIFIER.set(notifier); } /// Get the global push notifier, or `DefaultPushNotifier` if none was set. pub fn get_global_push_notifier() -> Arc { GLOBAL_PUSH_NOTIFIER .get() .cloned() .unwrap_or_else(|| Arc::new(DefaultPushNotifier)) } /// Default push notifier using platform-specific OS commands. /// Used as a fallback when no Tauri app handle is available. pub struct DefaultPushNotifier; /// Cache for WSL root path from PowerShell static WSL_ROOT_PATH_CACHE: OnceLock> = OnceLock::new(); #[async_trait] impl PushNotifier for DefaultPushNotifier { async fn send(&self, title: &str, message: &str, _workspace_id: Option) { if cfg!(target_os = "macos") { send_macos_notification(title, message).await; } else if cfg!(target_os = "linux") && !utils::is_wsl2() { send_linux_notification(title, message).await; } else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && utils::is_wsl2()) { send_windows_notification(title, message).await; } } } /// Service for handling cross-platform notifications including sound alerts and push notifications #[derive(Clone)] pub struct NotificationService { config: Arc>, push_notifier: Arc, } impl std::fmt::Debug for NotificationService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NotificationService") .field("config", &self.config) .finish() } } impl NotificationService { pub fn new(config: Arc>) -> Self { Self { config, push_notifier: get_global_push_notifier(), } } pub fn new_with_notifier( config: Arc>, push_notifier: Arc, ) -> Self { Self { config, push_notifier, } } /// Send both sound and push notifications if enabled. /// `workspace_id` is forwarded to the push notifier so Tauri can emit a /// navigation event when the notification is clicked. pub async fn notify(&self, title: &str, message: &str, workspace_id: Option) { let config = self.config.read().await.notifications.clone(); if config.sound_enabled { Self::play_sound_notification(&config.sound_file).await; } if config.push_enabled { self.push_notifier.send(title, message, workspace_id).await; } } /// Play a system sound notification across platforms async fn play_sound_notification(sound_file: &SoundFile) { let file_path = match sound_file.get_path().await { Ok(path) => path, Err(e) => { tracing::error!("Failed to create cached sound file: {}", e); return; } }; // Use platform-specific sound notification // Note: spawn() calls are intentionally not awaited - sound notifications should be fire-and-forget if cfg!(target_os = "macos") { let _ = tokio::process::Command::new("afplay") .arg(&file_path) .spawn(); } else if cfg!(target_os = "linux") && !utils::is_wsl2() { // Try different Linux audio players if tokio::process::Command::new("paplay") .arg(&file_path) .spawn() .is_ok() { // Success with paplay } else if tokio::process::Command::new("aplay") .arg(&file_path) .spawn() .is_ok() { // Success with aplay } else { // Try system bell as fallback let _ = tokio::process::Command::new("echo") .arg("-e") .arg("\\a") .spawn(); } } else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && utils::is_wsl2()) { // Convert WSL path to Windows path if in WSL2 let file_path = if utils::is_wsl2() { if let Some(windows_path) = wsl_to_windows_path(&file_path).await { windows_path } else { file_path.to_string_lossy().to_string() } } else { file_path.to_string_lossy().to_string() }; let _ = tokio::process::Command::new("powershell.exe") .arg("-c") .arg(format!( r#"(New-Object Media.SoundPlayer "{file_path}").PlaySync()"# )) .no_window() .spawn(); } } } // --- Platform-specific push notification helpers (used by DefaultPushNotifier) --- /// Send macOS notification using osascript async fn send_macos_notification(title: &str, message: &str) { let script = format!( r#"display notification "{message}" with title "{title}" sound name "Glass""#, message = message.replace('"', r#"\""#), title = title.replace('"', r#"\""#) ); let _ = tokio::process::Command::new("osascript") .arg("-e") .arg(script) .spawn(); } /// Send Linux notification using notify-rust async fn send_linux_notification(title: &str, message: &str) { use notify_rust::Notification; let title = title.to_string(); let message = message.to_string(); let _handle = tokio::task::spawn_blocking(move || { match Notification::new() .summary(&title) .body(&message) .timeout(10000) .show() { Ok(_) => {} Err(e) => { let err_str = e.to_string(); if err_str.contains("ServiceUnknown") || err_str.contains("org.freedesktop.Notifications") { tracing::warn!("Linux notification daemon not available: {}", e); } else { tracing::warn!("Failed to send Linux notification: {}", e); } } } }); drop(_handle); // Don't await, fire-and-forget } /// Send Windows/WSL notification using PowerShell toast script async fn send_windows_notification(title: &str, message: &str) { let script_path = match utils::get_powershell_script().await { Ok(path) => path, Err(e) => { tracing::error!("Failed to get PowerShell script: {}", e); return; } }; // Convert WSL path to Windows path if in WSL2 let script_path_str = if utils::is_wsl2() { if let Some(windows_path) = wsl_to_windows_path(&script_path).await { windows_path } else { script_path.to_string_lossy().to_string() } } else { script_path.to_string_lossy().to_string() }; let _ = tokio::process::Command::new("powershell.exe") .arg("-NoProfile") .arg("-ExecutionPolicy") .arg("Bypass") .arg("-File") .arg(script_path_str) .arg("-Title") .arg(title) .arg("-Message") .arg(message) .no_window() .spawn(); } /// Get WSL root path via PowerShell (cached) async fn get_wsl_root_path() -> Option { if let Some(cached) = WSL_ROOT_PATH_CACHE.get() { return cached.clone(); } match tokio::process::Command::new("powershell.exe") .arg("-c") .arg("(Get-Location).Path -replace '^.*::', ''") .current_dir("/") .no_window() .output() .await { Ok(output) => { match String::from_utf8(output.stdout) { Ok(pwd_str) => { let pwd = pwd_str.trim(); tracing::info!("WSL root path detected: {}", pwd); // Cache the result let _ = WSL_ROOT_PATH_CACHE.set(Some(pwd.to_string())); return Some(pwd.to_string()); } Err(e) => { tracing::error!("Failed to parse PowerShell pwd output as UTF-8: {}", e); } } } Err(e) => { tracing::error!("Failed to execute PowerShell pwd command: {}", e); } } // Cache the failure result let _ = WSL_ROOT_PATH_CACHE.set(None); None } /// Convert WSL path to Windows UNC path for PowerShell async fn wsl_to_windows_path(wsl_path: &std::path::Path) -> Option { let path_str = wsl_path.to_string_lossy(); // Relative paths work fine as-is in PowerShell if !path_str.starts_with('/') { tracing::debug!("Using relative path as-is: {}", path_str); return Some(path_str.to_string()); } // Get cached WSL root path from PowerShell if let Some(wsl_root) = get_wsl_root_path().await { // Simply concatenate WSL root with the absolute path - PowerShell doesn't mind / let windows_path = format!("{wsl_root}{path_str}"); tracing::debug!("WSL path converted: {} -> {}", path_str, windows_path); Some(windows_path) } else { tracing::error!( "Failed to determine WSL root path for conversion: {}", path_str ); None } } ================================================ FILE: crates/services/src/services/oauth_credentials.rs ================================================ use std::path::PathBuf; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; /// OAuth credentials containing the JWT tokens issued by the remote OAuth service. /// The `access_token` is short-lived; `refresh_token` allows minting a new pair. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Credentials { pub access_token: Option, pub refresh_token: String, pub expires_at: Option>, } impl Credentials { pub fn expires_soon(&self, leeway: ChronoDuration) -> bool { match (self.access_token.as_ref(), self.expires_at.as_ref()) { (Some(_), Some(exp)) => Utc::now() + leeway >= *exp, _ => true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredCredentials { refresh_token: String, } impl From for Credentials { fn from(value: StoredCredentials) -> Self { Self { access_token: None, refresh_token: value.refresh_token, expires_at: None, } } } /// Service for managing OAuth credentials (JWT tokens) in memory and persistent storage. /// The token is loaded into memory on startup and persisted to disk on save. pub struct OAuthCredentials { path: PathBuf, inner: RwLock>, } impl OAuthCredentials { pub fn new(path: PathBuf) -> Self { Self { path, inner: RwLock::new(None), } } pub async fn load(&self) -> std::io::Result<()> { let creds = self.load_from_file().await?.map(Credentials::from); *self.inner.write().await = creds; Ok(()) } pub async fn save(&self, creds: &Credentials) -> std::io::Result<()> { let stored = StoredCredentials { refresh_token: creds.refresh_token.clone(), }; self.save_to_file(&stored).await?; *self.inner.write().await = Some(creds.clone()); Ok(()) } pub async fn clear(&self) -> std::io::Result<()> { let _ = std::fs::remove_file(&self.path); *self.inner.write().await = None; Ok(()) } pub async fn get(&self) -> Option { self.inner.read().await.clone() } async fn load_from_file(&self) -> std::io::Result> { if !self.path.exists() { return Ok(None); } let bytes = std::fs::read(&self.path)?; match serde_json::from_slice::(&bytes) { Ok(creds) => Ok(Some(creds)), Err(e) => { tracing::warn!(?e, "failed to parse credentials file, renaming to .bad"); let bad = self.path.with_extension("bad"); let _ = std::fs::rename(&self.path, bad); Ok(None) } } } async fn save_to_file(&self, creds: &StoredCredentials) -> std::io::Result<()> { let tmp = self.path.with_extension("tmp"); let file = { let mut opts = std::fs::OpenOptions::new(); opts.create(true).truncate(true).write(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } opts.open(&tmp)? }; serde_json::to_writer_pretty(&file, creds)?; file.sync_all()?; drop(file); std::fs::rename(&tmp, &self.path)?; Ok(()) } } ================================================ FILE: crates/services/src/services/pr_monitor.rs ================================================ use std::time::Duration; use api_types::{PullRequestStatus, UpsertPullRequestRequest}; use chrono::Utc; use db::{ DBService, models::{ merge::{Merge, MergeStatus, PrMerge}, workspace::{Workspace, WorkspaceError}, }, }; use git_host::{GitHostError, GitHostProvider, GitHostService}; use serde_json::json; use sqlx::error::Error as SqlxError; use thiserror::Error; use tokio::time::interval; use tracing::{debug, error, info, warn}; use crate::services::{ analytics::AnalyticsContext, container::ContainerService, remote_client::RemoteClient, remote_sync, }; #[derive(Debug, Error)] enum PrMonitorError { #[error(transparent)] GitHostError(#[from] GitHostError), #[error(transparent)] WorkspaceError(#[from] WorkspaceError), #[error(transparent)] Sqlx(#[from] SqlxError), } impl PrMonitorError { fn is_environmental(&self) -> bool { matches!( self, PrMonitorError::GitHostError( GitHostError::CliNotInstalled { .. } | GitHostError::NotAGitRepository(_) ) ) } } /// Service to monitor PRs and update task status when they are merged pub struct PrMonitorService { db: DBService, poll_interval: Duration, analytics: Option, container: C, remote_client: Option, } impl PrMonitorService { pub async fn spawn( db: DBService, analytics: Option, container: C, remote_client: Option, ) -> tokio::task::JoinHandle<()> { let service = Self { db, poll_interval: Duration::from_secs(60), // Check every minute analytics, container, remote_client, }; tokio::spawn(async move { service.start().await; }) } async fn start(&self) { info!( "Starting PR monitoring service with interval {:?}", self.poll_interval ); let mut interval = interval(self.poll_interval); loop { interval.tick().await; if let Err(e) = self.check_all_open_prs().await { error!("Error checking open PRs: {}", e); } } } /// Check all open PRs for updates with the provided GitHub token async fn check_all_open_prs(&self) -> Result<(), PrMonitorError> { let open_prs = Merge::get_open_prs(&self.db.pool).await?; if open_prs.is_empty() { debug!("No open PRs to check"); return Ok(()); } info!("Checking {} open PRs", open_prs.len()); for pr_merge in open_prs { if let Err(e) = self.check_pr_status(&pr_merge).await { if e.is_environmental() { warn!( "Skipping PR #{} for workspace {} due to environmental error: {}", pr_merge.pr_info.number, pr_merge.workspace_id, e ); } else { error!( "Error checking PR #{} for workspace {}: {}", pr_merge.pr_info.number, pr_merge.workspace_id, e ); } } } Ok(()) } /// Check the status of a specific PR async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> { let git_host = GitHostService::from_url(&pr_merge.pr_info.url)?; let pr_status = git_host.get_pr_status(&pr_merge.pr_info.url).await?; debug!( "PR #{} status: {:?} (was open)", pr_merge.pr_info.number, pr_status.status ); // Update the PR status in the database if !matches!(&pr_status.status, MergeStatus::Open) { // Update merge status with the latest information from git host Merge::update_status( &self.db.pool, pr_merge.id, pr_status.status.clone(), pr_status.merge_commit_sha.clone(), ) .await?; self.sync_pr_to_remote(pr_merge, &pr_status.status, pr_status.merge_commit_sha) .await; // If the PR was merged, archive the workspace if matches!(&pr_status.status, MergeStatus::Merged) && let Some(workspace) = Workspace::find_by_id(&self.db.pool, pr_merge.workspace_id).await? { let open_pr_count = Merge::count_open_prs_for_workspace(&self.db.pool, workspace.id).await?; if open_pr_count == 0 { info!( "PR #{} was merged, archiving workspace {}", pr_merge.pr_info.number, workspace.id ); if !workspace.pinned && let Err(e) = self.container.archive_workspace(workspace.id).await { error!("Failed to archive workspace {}: {}", workspace.id, e); } } else { info!( "PR #{} was merged, leaving workspace {} active with {} open PR(s)", pr_merge.pr_info.number, workspace.id, open_pr_count ); } // Track analytics event if let Some(analytics) = &self.analytics { analytics.analytics_service.track_event( &analytics.user_id, "pr_merged", Some(json!({ "workspace_id": workspace.id.to_string(), })), ); } } } Ok(()) } /// Sync PR status to remote server async fn sync_pr_to_remote( &self, pr_merge: &PrMerge, status: &MergeStatus, merge_commit_sha: Option, ) { let Some(client) = &self.remote_client else { return; }; let pr_status = match status { MergeStatus::Open => PullRequestStatus::Open, MergeStatus::Merged => PullRequestStatus::Merged, MergeStatus::Closed => PullRequestStatus::Closed, MergeStatus::Unknown => return, }; let merged_at = if matches!(status, MergeStatus::Merged) { Some(Utc::now()) } else { None }; let client = client.clone(); let request = UpsertPullRequestRequest { url: pr_merge.pr_info.url.clone(), number: pr_merge.pr_info.number as i32, status: pr_status, merged_at, merge_commit_sha, target_branch_name: pr_merge.target_branch_name.clone(), local_workspace_id: pr_merge.workspace_id, }; tokio::spawn(async move { remote_sync::sync_pr_to_remote(&client, request).await; }); } } ================================================ FILE: crates/services/src/services/qa_repos.rs ================================================ //! QA Mode: Hardcoded repository management for testing //! //! This module provides two hardcoded QA repositories that are cloned //! to a persistent temp directory and returned as the only "recent" repos. use std::{path::PathBuf, process::Command}; use once_cell::sync::Lazy; use tracing::{info, warn}; use utils::command_ext::NoWindowExt; use super::filesystem::{DirectoryEntry, FilesystemError}; /// QA repository URLs and names const QA_REPOS: &[(&str, &str)] = &[ ("internal-qa-1", "https://github.com/BloopAI/internal-qa-1"), ("internal-qa-2", "https://github.com/BloopAI/internal-qa-2"), ]; /// Persistent directory for QA repos - survives server restarts static QA_REPOS_DIR: Lazy = Lazy::new(|| { let dir = utils::path::get_vibe_kanban_temp_dir().join("qa-repos"); if let Err(e) = std::fs::create_dir_all(&dir) { warn!("Failed to create QA repos directory: {}", e); } info!("QA repos directory: {:?}", dir); dir }); /// Get the list of QA repositories, cloning them if necessary. /// /// This function is called instead of the normal filesystem git repo discovery /// when QA mode is enabled. pub fn get_qa_repos() -> Result, FilesystemError> { let base_dir = &*QA_REPOS_DIR; // Ensure repos are cloned clone_qa_repos_if_needed(base_dir); // Build DirectoryEntry for each repo let entries = QA_REPOS .iter() .filter_map(|(name, _url)| { let repo_path = base_dir.join(name); if repo_path.exists() && repo_path.join(".git").exists() { let last_modified = std::fs::metadata(&repo_path) .ok() .and_then(|m| m.modified().ok()) .map(|t| t.elapsed().unwrap_or_default().as_secs()); Some(DirectoryEntry { name: name.to_string(), path: repo_path, is_directory: true, is_git_repo: true, last_modified, }) } else { warn!("QA repo {} not found at {:?}", name, repo_path); None } }) .collect(); Ok(entries) } /// Clone QA repositories if they don't already exist fn clone_qa_repos_if_needed(base_dir: &std::path::Path) { for (name, url) in QA_REPOS { let repo_path = base_dir.join(name); if repo_path.join(".git").exists() { info!("QA repo {} already exists at {:?}", name, repo_path); continue; } info!("Cloning QA repo {} from {} to {:?}", name, url, repo_path); // Use git CLI for reliable TLS support (git2 has TLS issues) let output = Command::new("git") .args(["clone", "--depth", "1", url, &repo_path.to_string_lossy()]) .no_window() .output(); match output { Ok(result) if result.status.success() => { info!("Successfully cloned QA repo {}", name); } Ok(result) => { warn!( "Failed to clone QA repo {}: {}", name, String::from_utf8_lossy(&result.stderr) ); // Try to clean up partial clone let _ = std::fs::remove_dir_all(&repo_path); } Err(e) => { warn!("Failed to run git clone for {}: {}", name, e); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_qa_repos_dir_is_persistent() { let dir1 = &*QA_REPOS_DIR; let dir2 = &*QA_REPOS_DIR; assert_eq!(dir1, dir2); assert!(dir1.ends_with("qa-repos")); } } ================================================ FILE: crates/services/src/services/queued_message.rs ================================================ use std::sync::Arc; use chrono::{DateTime, Utc}; use dashmap::DashMap; use db::models::scratch::DraftFollowUpData; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; /// Represents a queued follow-up message for a session #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct QueuedMessage { /// The session this message is queued for pub session_id: Uuid, /// The follow-up data (message + variant) pub data: DraftFollowUpData, /// Timestamp when the message was queued pub queued_at: DateTime, } /// Status of the queue for a session (for frontend display) #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "status", rename_all = "snake_case")] pub enum QueueStatus { /// No message queued Empty, /// Message is queued and waiting for execution to complete Queued { message: QueuedMessage }, } /// In-memory service for managing queued follow-up messages. /// One queued message per session. #[derive(Clone)] pub struct QueuedMessageService { queue: Arc>, } impl QueuedMessageService { pub fn new() -> Self { Self { queue: Arc::new(DashMap::new()), } } /// Queue a message for a session. Replaces any existing queued message. pub fn queue_message(&self, session_id: Uuid, data: DraftFollowUpData) -> QueuedMessage { let queued = QueuedMessage { session_id, data, queued_at: Utc::now(), }; self.queue.insert(session_id, queued.clone()); queued } /// Cancel/remove a queued message for a session pub fn cancel_queued(&self, session_id: Uuid) -> Option { self.queue.remove(&session_id).map(|(_, v)| v) } /// Get the queued message for a session (if any) pub fn get_queued(&self, session_id: Uuid) -> Option { self.queue.get(&session_id).map(|r| r.clone()) } /// Take (remove and return) the queued message for a session. /// Used by finalization flow to consume the queued message. pub fn take_queued(&self, session_id: Uuid) -> Option { self.queue.remove(&session_id).map(|(_, v)| v) } /// Check if a session has a queued message pub fn has_queued(&self, session_id: Uuid) -> bool { self.queue.contains_key(&session_id) } /// Get queue status for frontend display pub fn get_status(&self, session_id: Uuid) -> QueueStatus { match self.get_queued(session_id) { Some(msg) => QueueStatus::Queued { message: msg }, None => QueueStatus::Empty, } } } impl Default for QueuedMessageService { fn default() -> Self { Self::new() } } ================================================ FILE: crates/services/src/services/remote_client.rs ================================================ //! OAuth client for authorization-code handoffs with automatic retries. use std::time::Duration; use api_types::{ AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse, CreateIssueAssigneeRequest, CreateIssueRelationshipRequest, CreateIssueRequest, CreateIssueTagRequest, CreateOrganizationRequest, CreateOrganizationResponse, CreateWorkspaceRequest, DeleteResponse, DeleteWorkspaceRequest, GetInvitationResponse, GetOrganizationResponse, HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest, HandoffRedeemResponse, Issue, IssueAssignee, IssueRelationship, IssueTag, ListAttachmentsResponse, ListInvitationsResponse, ListIssueAssigneesResponse, ListIssueRelationshipsResponse, ListIssueTagsResponse, ListIssuesResponse, ListMembersResponse, ListOrganizationsResponse, ListProjectStatusesResponse, ListProjectsResponse, ListPullRequestsResponse, ListTagsResponse, MutationResponse, Organization, ProfileResponse, RevokeInvitationRequest, SearchIssuesRequest, Tag, TokenRefreshRequest, TokenRefreshResponse, UpdateIssueRequest, UpdateMemberRoleRequest, UpdateMemberRoleResponse, UpdateOrganizationRequest, UpdateWorkspaceRequest, UpsertPullRequestRequest, Workspace, }; use backon::{ExponentialBuilder, Retryable}; use chrono::Duration as ChronoDuration; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; use url::Url; use utils::jwt::extract_expiration; use uuid::Uuid; use super::{auth::AuthContext, oauth_credentials::Credentials}; #[derive(Debug, Clone, Error)] pub enum RemoteClientError { #[error("network error: {0}")] Transport(String), #[error("timeout")] Timeout, #[error("token refresh timed out")] TokenRefreshTimeout, #[error("http {status}: {body}")] Http { status: u16, body: String }, #[error("api error: {0:?}")] Api(HandoffErrorCode), #[error("unauthorized")] Auth, #[error("json error: {0}")] Serde(String), #[error("url error: {0}")] Url(String), #[error("credentials storage error: {0}")] Storage(String), #[error("invalid access token: {0}")] Token(String), } impl RemoteClientError { /// Returns true if the error is transient and should be retried. pub fn should_retry(&self) -> bool { match self { Self::Transport(_) | Self::Timeout => true, Self::Http { status, .. } => (500..=599).contains(status), _ => false, } } } #[derive(Debug, Clone)] pub enum HandoffErrorCode { UnsupportedProvider, InvalidReturnUrl, InvalidChallenge, ProviderError, NotFound, Expired, AccessDenied, InternalError, Other(String), } fn map_error_code(code: Option<&str>) -> HandoffErrorCode { match code.unwrap_or("internal_error") { "unsupported_provider" => HandoffErrorCode::UnsupportedProvider, "invalid_return_url" => HandoffErrorCode::InvalidReturnUrl, "invalid_challenge" => HandoffErrorCode::InvalidChallenge, "provider_error" => HandoffErrorCode::ProviderError, "not_found" => HandoffErrorCode::NotFound, "expired" | "expired_token" => HandoffErrorCode::Expired, "access_denied" => HandoffErrorCode::AccessDenied, "internal_error" => HandoffErrorCode::InternalError, other => HandoffErrorCode::Other(other.to_string()), } } #[derive(Deserialize)] struct ApiErrorResponse { error: String, } #[derive(Debug, Clone, Copy)] struct RequestTimeoutOptions { timeout: Duration, retry_on_timeout: bool, } /// HTTP client for the remote OAuth server with automatic retries. pub struct RemoteClient { base: Url, http: Client, auth_context: AuthContext, } impl std::fmt::Debug for RemoteClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RemoteClient") .field("base", &self.base) .field("http", &self.http) .field("auth_context", &"") .finish() } } impl Clone for RemoteClient { fn clone(&self) -> Self { Self { base: self.base.clone(), http: self.http.clone(), auth_context: self.auth_context.clone(), } } } impl RemoteClient { const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const TOKEN_REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_mins(5); const TOKEN_REFRESH_LEEWAY_SECS: i64 = 20; pub fn new(base_url: &str, auth_context: AuthContext) -> Result { let base = Url::parse(base_url).map_err(|e| RemoteClientError::Url(e.to_string()))?; let mut builder = Client::builder() .timeout(Self::REQUEST_TIMEOUT) .user_agent(concat!("remote-client/", env!("CARGO_PKG_VERSION"))); #[cfg(debug_assertions)] { builder = builder.danger_accept_invalid_certs(true); } let http = builder .build() .map_err(|e| RemoteClientError::Transport(e.to_string()))?; Ok(Self { base, http, auth_context, }) } /// Returns a valid access token, refreshing when it's about to expire. fn require_token( &self, ) -> std::pin::Pin< Box> + Send + '_>, > { Box::pin(async move { let leeway = ChronoDuration::seconds(Self::TOKEN_REFRESH_LEEWAY_SECS); let creds = self .auth_context .get_credentials() .await .ok_or(RemoteClientError::Auth)?; if let Some(token) = creds.access_token.as_ref() && !creds.expires_soon(leeway) { return Ok(token.clone()); } let refreshed = { let _refresh_guard = self.auth_context.refresh_guard().await; let latest = self .auth_context .get_credentials() .await .ok_or(RemoteClientError::Auth)?; if let Some(token) = latest.access_token.as_ref() && !latest.expires_soon(leeway) { return Ok(token.clone()); } self.refresh_credentials(&latest).await }; match refreshed { Ok(updated) => updated.access_token.ok_or(RemoteClientError::Auth), Err(RemoteClientError::Auth) => { let _ = self.auth_context.clear_credentials().await; Err(RemoteClientError::Auth) } Err(RemoteClientError::TokenRefreshTimeout) => { tracing::error!( "Refresh token request timed out after {} minutes. Discarding the refresh token and forcing re-login.", Self::TOKEN_REFRESH_REQUEST_TIMEOUT.as_secs() / 60 ); let _ = self.auth_context.clear_credentials().await; Err(RemoteClientError::TokenRefreshTimeout) } Err(err) => Err(err), } }) } async fn refresh_credentials( &self, creds: &Credentials, ) -> Result { let response = self.refresh_token_request(&creds.refresh_token).await?; let access_token = response.access_token; let refresh_token = response.refresh_token; let expires_at = extract_expiration(&access_token) .map_err(|err| RemoteClientError::Token(err.to_string()))?; let new_creds = Credentials { access_token: Some(access_token), refresh_token, expires_at: Some(expires_at), }; self.auth_context .save_credentials(&new_creds) .await .map_err(|e| RemoteClientError::Storage(e.to_string()))?; Ok(new_creds) } async fn refresh_token_request( &self, refresh_token: &str, ) -> Result { let request = TokenRefreshRequest { refresh_token: refresh_token.to_string(), }; let timeout_options = RequestTimeoutOptions { timeout: Self::TOKEN_REFRESH_REQUEST_TIMEOUT, retry_on_timeout: false, }; self.post_public_with_timeout_options("/v1/tokens/refresh", Some(&request), timeout_options) .await .map_err(|e| { if matches!(e, RemoteClientError::Timeout) { RemoteClientError::TokenRefreshTimeout } else { e } }) .map_err(|e| self.map_api_error(e)) } /// Returns the base URL for the client. pub fn base_url(&self) -> &str { self.base.as_str() } /// Returns a valid access token for use-cases like maintaining a websocket connection. pub async fn access_token(&self) -> Result { self.require_token().await } /// Initiates an authorization-code handoff for the given provider. pub async fn handoff_init( &self, request: &HandoffInitRequest, ) -> Result { self.post_public("/v1/oauth/web/init", Some(request)) .await .map_err(|e| self.map_api_error(e)) } /// Redeems an application code for an access token. pub async fn handoff_redeem( &self, request: &HandoffRedeemRequest, ) -> Result { self.post_public("/v1/oauth/web/redeem", Some(request)) .await .map_err(|e| self.map_api_error(e)) } /// Gets an invitation by token (public, no auth required). pub async fn get_invitation( &self, invitation_token: &str, ) -> Result { self.get_public(&format!("/v1/invitations/{invitation_token}")) .await } async fn send( &self, method: reqwest::Method, path: &str, requires_auth: bool, body: Option<&B>, ) -> Result where B: Serialize, { self.send_internal(method, path, requires_auth, body, None) .await } async fn send_internal( &self, method: reqwest::Method, path: &str, requires_auth: bool, body: Option<&B>, timeout_options: Option, ) -> Result where B: Serialize, { self.send_internal_with_request(method, path, requires_auth, timeout_options, |req| { if let Some(body) = body { req.json(body) } else { req } }) .await } async fn send_internal_with_request( &self, method: reqwest::Method, path: &str, requires_auth: bool, timeout_options: Option, customize_request: F, ) -> Result where F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder, { let url = self .base .join(path) .map_err(|e| RemoteClientError::Url(e.to_string()))?; let retry_on_timeout = timeout_options.is_none_or(|o| o.retry_on_timeout); let operation = || async { let mut req = self .http .request(method.clone(), url.clone()) .header("X-Client-Version", env!("CARGO_PKG_VERSION")) .header("X-Client-Type", "local-backend"); if let Some(t) = timeout_options.map(|o| o.timeout) { req = req.timeout(t); } if requires_auth { let token = self.require_token().await?; req = req.bearer_auth(token); } req = customize_request(req); let res = req.send().await.map_err(map_reqwest_error)?; match res.status() { s if s.is_success() => Ok(res), StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(RemoteClientError::Auth), s => { let status = s.as_u16(); let body = res.text().await.unwrap_or_default(); Err(RemoteClientError::Http { status, body }) } } }; operation .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_millis(500)) .with_max_delay(Duration::from_secs(2)) .with_max_times(2) .with_jitter(), ) .when(move |e: &RemoteClientError| { if !e.should_retry() { return false; } retry_on_timeout || !matches!(e, RemoteClientError::Timeout) }) .notify(|e, dur| { warn!( "Remote call failed, retrying after {:.2}s: {}", dur.as_secs_f64(), e ) }) .await } // Public endpoint helpers (no auth required) async fn get_public(&self, path: &str) -> Result where T: for<'de> Deserialize<'de>, { let res = self .send(reqwest::Method::GET, path, false, None::<&()>) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } async fn post_public(&self, path: &str, body: Option<&B>) -> Result where T: for<'de> Deserialize<'de>, B: Serialize, { let res = self.send(reqwest::Method::POST, path, false, body).await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } async fn post_public_with_timeout_options( &self, path: &str, body: Option<&B>, timeout_options: RequestTimeoutOptions, ) -> Result where T: for<'de> Deserialize<'de>, B: Serialize, { let res = self .send_internal( reqwest::Method::POST, path, false, body, Some(timeout_options), ) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } // Authenticated endpoint helpers (require token) async fn get_authed(&self, path: &str) -> Result where T: for<'de> Deserialize<'de>, { let res = self .send(reqwest::Method::GET, path, true, None::<&()>) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } pub async fn post_authed( &self, path: &str, body: Option<&B>, ) -> Result where T: for<'de> Deserialize<'de>, B: Serialize, { let res = self.send(reqwest::Method::POST, path, true, body).await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } async fn patch_authed(&self, path: &str, body: &B) -> Result where T: for<'de> Deserialize<'de>, B: Serialize, { let res = self .send(reqwest::Method::PATCH, path, true, Some(body)) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } async fn delete_authed(&self, path: &str) -> Result<(), RemoteClientError> { self.send(reqwest::Method::DELETE, path, true, None::<&()>) .await?; Ok(()) } async fn delete_authed_with_body( &self, path: &str, body: &B, ) -> Result<(), RemoteClientError> where B: Serialize, { self.send(reqwest::Method::DELETE, path, true, Some(body)) .await?; Ok(()) } fn map_api_error(&self, err: RemoteClientError) -> RemoteClientError { if let RemoteClientError::Http { body, .. } = &err && let Ok(api_err) = serde_json::from_str::(body) { return RemoteClientError::Api(map_error_code(Some(&api_err.error))); } err } /// Fetches user profile. pub async fn profile(&self) -> Result { self.get_authed("/v1/profile").await } /// Revokes the session associated with the token. pub async fn logout(&self) -> Result<(), RemoteClientError> { self.delete_authed("/v1/oauth/logout").await } /// Lists organizations for the authenticated user. pub async fn list_organizations(&self) -> Result { self.get_authed("/v1/organizations").await } /// Gets a specific organization by ID. pub async fn get_organization( &self, org_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/organizations/{org_id}")) .await } /// Creates a new organization. pub async fn create_organization( &self, request: &CreateOrganizationRequest, ) -> Result { self.post_authed("/v1/organizations", Some(request)).await } /// Updates an organization's name. pub async fn update_organization( &self, org_id: Uuid, request: &UpdateOrganizationRequest, ) -> Result { self.patch_authed(&format!("/v1/organizations/{org_id}"), request) .await } /// Deletes an organization. pub async fn delete_organization(&self, org_id: Uuid) -> Result<(), RemoteClientError> { self.delete_authed(&format!("/v1/organizations/{org_id}")) .await } /// Creates an invitation to an organization. pub async fn create_invitation( &self, org_id: Uuid, request: &CreateInvitationRequest, ) -> Result { self.post_authed( &format!("/v1/organizations/{org_id}/invitations"), Some(request), ) .await } /// Lists invitations for an organization. pub async fn list_invitations( &self, org_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/organizations/{org_id}/invitations")) .await } pub async fn revoke_invitation( &self, org_id: Uuid, invitation_id: Uuid, ) -> Result<(), RemoteClientError> { let body = RevokeInvitationRequest { invitation_id }; self.send( reqwest::Method::POST, &format!("/v1/organizations/{org_id}/invitations/revoke"), true, Some(&body), ) .await?; Ok(()) } /// Accepts an invitation. pub async fn accept_invitation( &self, invitation_token: &str, ) -> Result { self.post_authed( &format!("/v1/invitations/{invitation_token}/accept"), None::<&()>, ) .await } /// Lists members of an organization. pub async fn list_members( &self, org_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/organizations/{org_id}/members")) .await } /// Removes a member from an organization. pub async fn remove_member( &self, org_id: Uuid, user_id: Uuid, ) -> Result<(), RemoteClientError> { self.delete_authed(&format!("/v1/organizations/{org_id}/members/{user_id}")) .await } /// Updates a member's role in an organization. pub async fn update_member_role( &self, org_id: Uuid, user_id: Uuid, request: &UpdateMemberRoleRequest, ) -> Result { self.patch_authed( &format!("/v1/organizations/{org_id}/members/{user_id}/role"), request, ) .await } /// Deletes a workspace on the remote server by its local workspace ID. pub async fn delete_workspace( &self, local_workspace_id: Uuid, ) -> Result<(), RemoteClientError> { self.delete_authed_with_body( "/v1/workspaces", &DeleteWorkspaceRequest { local_workspace_id }, ) .await } /// Gets a workspace from the remote server by its local workspace ID. pub async fn get_workspace_by_local_id( &self, local_workspace_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/workspaces/by-local-id/{local_workspace_id}")) .await } /// Checks if a workspace exists on the remote server. pub async fn workspace_exists( &self, local_workspace_id: Uuid, ) -> Result { match self .send( reqwest::Method::HEAD, &format!("/v1/workspaces/exists/{local_workspace_id}"), true, None::<&()>, ) .await { Ok(_) => Ok(true), Err(RemoteClientError::Http { status: 404, .. }) => Ok(false), Err(e) => Err(e), } } /// Updates a workspace on the remote server. pub async fn update_workspace( &self, local_workspace_id: Uuid, name: Option>, archived: Option, files_changed: Option, lines_added: Option, lines_removed: Option, ) -> Result<(), RemoteClientError> { self.send( reqwest::Method::PATCH, "/v1/workspaces", true, Some(&UpdateWorkspaceRequest { local_workspace_id, name, archived, files_changed: files_changed.map(Some), lines_added: lines_added.map(Some), lines_removed: lines_removed.map(Some), }), ) .await?; Ok(()) } /// Triggers issue-status sync for a workspace that was merged locally without a PR. pub async fn sync_issue_status_from_local_workspace_merge( &self, local_workspace_id: Uuid, ) -> Result<(), RemoteClientError> { self.send( reqwest::Method::POST, &format!("/v1/workspaces/{local_workspace_id}/sync_issue_status_from_local_merge"), true, None::<&()>, ) .await?; Ok(()) } /// Creates a workspace on the remote server, linking it to a local workspace and an issue. pub async fn create_workspace( &self, request: CreateWorkspaceRequest, ) -> Result<(), RemoteClientError> { self.send( reqwest::Method::POST, "/v1/workspaces", true, Some(&request), ) .await?; Ok(()) } // ── Issues ────────────────────────────────────────────────────────── /// Lists issues for a project. pub async fn list_issues( &self, project_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issues?project_id={project_id}")) .await } /// Searches issues for a project using the canonical JSON request shape. pub async fn search_issues( &self, request: &SearchIssuesRequest, ) -> Result { self.post_authed("/v1/issues/search", Some(request)).await } /// Gets a single issue by ID. pub async fn get_issue(&self, issue_id: Uuid) -> Result { self.get_authed(&format!("/v1/issues/{issue_id}")).await } /// Creates a new issue. pub async fn create_issue( &self, request: &CreateIssueRequest, ) -> Result, RemoteClientError> { self.post_authed("/v1/issues", Some(request)).await } /// Updates an existing issue. pub async fn update_issue( &self, issue_id: Uuid, request: &UpdateIssueRequest, ) -> Result, RemoteClientError> { self.patch_authed(&format!("/v1/issues/{issue_id}"), request) .await } /// Deletes an issue. pub async fn delete_issue(&self, issue_id: Uuid) -> Result { let res = self .send( reqwest::Method::DELETE, &format!("/v1/issues/{issue_id}"), true, None::<&()>, ) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } // ── Issue Assignees ──────────────────────────────────────────────── /// Lists assignees for an issue. pub async fn list_issue_assignees( &self, issue_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issue_assignees?issue_id={issue_id}")) .await } /// Gets a single issue assignee by ID. pub async fn get_issue_assignee( &self, issue_assignee_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issue_assignees/{issue_assignee_id}")) .await } /// Creates a new issue assignee. pub async fn create_issue_assignee( &self, request: &CreateIssueAssigneeRequest, ) -> Result, RemoteClientError> { self.post_authed("/v1/issue_assignees", Some(request)).await } /// Deletes an issue assignee. pub async fn delete_issue_assignee( &self, issue_assignee_id: Uuid, ) -> Result { let res = self .send( reqwest::Method::DELETE, &format!("/v1/issue_assignees/{issue_assignee_id}"), true, None::<&()>, ) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } // ── Tags ─────────────────────────────────────────────────────────── /// Lists tags for a project. pub async fn list_tags(&self, project_id: Uuid) -> Result { self.get_authed(&format!("/v1/tags?project_id={project_id}")) .await } /// Gets a single tag by ID. pub async fn get_tag(&self, tag_id: Uuid) -> Result { self.get_authed(&format!("/v1/tags/{tag_id}")).await } // ── Issue Tags ───────────────────────────────────────────────────── /// Lists tags attached to an issue. pub async fn list_issue_tags( &self, issue_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issue_tags?issue_id={issue_id}")) .await } /// Gets a single issue-tag relation by ID. pub async fn get_issue_tag(&self, issue_tag_id: Uuid) -> Result { self.get_authed(&format!("/v1/issue_tags/{issue_tag_id}")) .await } /// Attaches a tag to an issue. pub async fn create_issue_tag( &self, request: &CreateIssueTagRequest, ) -> Result, RemoteClientError> { self.post_authed("/v1/issue_tags", Some(request)).await } /// Removes a tag from an issue. pub async fn delete_issue_tag( &self, issue_tag_id: Uuid, ) -> Result { let res = self .send( reqwest::Method::DELETE, &format!("/v1/issue_tags/{issue_tag_id}"), true, None::<&()>, ) .await?; res.json::() .await .map_err(|e| RemoteClientError::Serde(e.to_string())) } // ── Issue Relationships ──────────────────────────────────────────── /// Lists relationships for an issue. pub async fn list_issue_relationships( &self, issue_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issue_relationships?issue_id={issue_id}")) .await } /// Creates a new issue relationship. pub async fn create_issue_relationship( &self, request: &CreateIssueRelationshipRequest, ) -> Result, RemoteClientError> { self.post_authed("/v1/issue_relationships", Some(request)) .await } /// Deletes an issue relationship. pub async fn delete_issue_relationship( &self, relationship_id: Uuid, ) -> Result<(), RemoteClientError> { self.delete_authed(&format!("/v1/issue_relationships/{relationship_id}")) .await } // ── Remote Projects ───────────────────────────────────────────────── /// Gets a single remote project by ID. pub async fn get_remote_project( &self, project_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/projects/{project_id}")).await } /// Lists projects for an organization. pub async fn list_remote_projects( &self, organization_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/projects?organization_id={organization_id}")) .await } // ── Project Statuses ──────────────────────────────────────────────── /// Lists project statuses for a project (used for status name ↔ UUID mapping). pub async fn list_project_statuses( &self, project_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/project_statuses?project_id={project_id}")) .await } // ── Pull Requests ─────────────────────────────────────────────────── /// Upserts a pull request on the remote server. /// Creates if not exists, updates if exists. pub async fn upsert_pull_request( &self, request: UpsertPullRequestRequest, ) -> Result<(), RemoteClientError> { self.send( reqwest::Method::PUT, "/v1/pull_requests", true, Some(&request), ) .await?; Ok(()) } /// Lists pull requests linked to an issue. pub async fn list_pull_requests( &self, issue_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/pull_requests?issue_id={issue_id}")) .await } /// Lists attachments for an issue on the remote server. pub async fn list_issue_attachments( &self, issue_id: Uuid, ) -> Result { self.get_authed(&format!("/v1/issues/{issue_id}/attachments")) .await } /// Used for fetching from presigned Azure SAS URLs. pub async fn download_from_url(&self, url: &str) -> Result, RemoteClientError> { let res = self.http.get(url).send().await.map_err(map_reqwest_error)?; if !res.status().is_success() { return Err(RemoteClientError::Http { status: res.status().as_u16(), body: res.text().await.unwrap_or_default(), }); } let bytes = res .bytes() .await .map_err(|e| RemoteClientError::Transport(e.to_string()))?; Ok(bytes.to_vec()) } } fn map_reqwest_error(e: reqwest::Error) -> RemoteClientError { if e.is_timeout() { RemoteClientError::Timeout } else { RemoteClientError::Transport(e.to_string()) } } ================================================ FILE: crates/services/src/services/remote_sync.rs ================================================ use std::collections::HashSet; use api_types::{PullRequestStatus, UpsertPullRequestRequest}; use db::models::{ merge::{Merge, MergeStatus}, workspace::Workspace, }; use git::GitService; use sqlx::SqlitePool; use tracing::{debug, error}; use uuid::Uuid; use super::{ diff_stream::{self, DiffStats}, remote_client::{RemoteClient, RemoteClientError}, }; async fn update_workspace_on_remote( client: &RemoteClient, workspace_id: Uuid, name: Option>, archived: Option, stats: Option<&DiffStats>, ) { match client .update_workspace( workspace_id, name, archived, stats.map(|s| s.files_changed as i32), stats.map(|s| s.lines_added as i32), stats.map(|s| s.lines_removed as i32), ) .await { Ok(()) => { debug!("Synced workspace {} to remote", workspace_id); } Err(RemoteClientError::Auth) => { debug!("Workspace {} sync skipped: not authenticated", workspace_id); } Err(RemoteClientError::Http { status: 404, .. }) => { debug!( "Workspace {} disappeared from remote before update, skipping sync", workspace_id ); } Err(e) => { error!("Failed to sync workspace {} to remote: {}", workspace_id, e); } } } /// Syncs workspace data to the remote server. /// First checks if the workspace exists on remote, then updates if it does. pub async fn sync_workspace_to_remote( client: &RemoteClient, workspace_id: Uuid, name: Option>, archived: Option, stats: Option<&DiffStats>, ) { // First check if workspace exists on remote match client.workspace_exists(workspace_id).await { Ok(false) => { debug!( "Workspace {} not found on remote, skipping sync", workspace_id ); return; } Err(RemoteClientError::Auth) => { debug!("Workspace {} sync skipped: not authenticated", workspace_id); return; } Err(e) => { error!( "Failed to check workspace {} existence on remote: {}", workspace_id, e ); return; } Ok(true) => {} } // Workspace exists, proceed with update update_workspace_on_remote(client, workspace_id, name, archived, stats).await; } /// Syncs issue status to remote for a workspace merged locally without a PR. pub async fn sync_local_workspace_merge_to_remote(client: &RemoteClient, workspace_id: Uuid) { match client .sync_issue_status_from_local_workspace_merge(workspace_id) .await { Ok(()) => { debug!( "Synced local workspace merge status to remote for workspace {}", workspace_id ); } Err(RemoteClientError::Auth) => { debug!( "Local workspace merge sync skipped for workspace {}: not authenticated", workspace_id ); } Err(RemoteClientError::Http { status: 404, .. }) => { debug!( "Local workspace merge sync skipped for workspace {}: workspace not found on remote", workspace_id ); } Err(e) => { error!( "Failed to sync local workspace merge status for workspace {}: {}", workspace_id, e ); } } } async fn upsert_pr_on_remote(client: &RemoteClient, request: UpsertPullRequestRequest) { let number = request.number; let workspace_id = request.local_workspace_id; // Workspace exists, proceed with PR upsert match client.upsert_pull_request(request).await { Ok(()) => { debug!("Synced PR #{} to remote", number); } Err(RemoteClientError::Auth) => { debug!("PR #{} sync skipped: not authenticated", number); } Err(RemoteClientError::Http { status: 404, .. }) => { debug!( "PR #{} workspace {} not found on remote, skipping sync", number, workspace_id ); } Err(e) => { error!("Failed to sync PR #{} to remote: {}", number, e); } } } /// Syncs PR data to the remote server. /// First checks if the workspace exists on remote, then upserts the PR if it does. pub async fn sync_pr_to_remote(client: &RemoteClient, request: UpsertPullRequestRequest) { // First check if workspace exists on remote match client.workspace_exists(request.local_workspace_id).await { Ok(false) => { debug!( "PR #{} workspace {} not found on remote, skipping sync", request.number, request.local_workspace_id ); return; } Err(RemoteClientError::Auth) => { debug!("PR #{} sync skipped: not authenticated", request.number); return; } Err(e) => { error!( "Failed to check workspace {} existence on remote: {}", request.local_workspace_id, e ); return; } Ok(true) => {} } upsert_pr_on_remote(client, request).await; } fn map_pr_status(status: &MergeStatus) -> PullRequestStatus { match status { MergeStatus::Open => PullRequestStatus::Open, MergeStatus::Merged => PullRequestStatus::Merged, MergeStatus::Closed => PullRequestStatus::Closed, MergeStatus::Unknown => PullRequestStatus::Open, } } /// Syncs all linked workspaces and their PRs to the remote server. /// Used after login to catch up on any changes made while logged out. pub async fn sync_all_linked_workspaces( client: &RemoteClient, pool: &SqlitePool, git: &GitService, ) { // Sync workspace stats let workspaces = match Workspace::fetch_all(pool).await { Ok(ws) => ws, Err(e) => { error!("Failed to fetch workspaces for post-login sync: {}", e); return; } }; let mut linked_workspace_ids = HashSet::new(); for workspace in &workspaces { match client.workspace_exists(workspace.id).await { Ok(true) => { linked_workspace_ids.insert(workspace.id); } Ok(false) => { debug!( "Workspace {} not found on remote, skipping post-login sync", workspace.id ); continue; } Err(RemoteClientError::Auth) => { debug!("Post-login workspace sync skipped: not authenticated"); return; } Err(e) => { error!( "Failed to check workspace {} existence on remote during post-login sync: {}", workspace.id, e ); continue; } } let stats = diff_stream::compute_diff_stats(pool, git, workspace).await; update_workspace_on_remote( client, workspace.id, workspace.name.clone().map(Some), Some(workspace.archived), stats.as_ref(), ) .await; } if linked_workspace_ids.is_empty() { debug!("Post-login workspace sync completed: no linked workspaces found"); return; } // Sync all PR data let pr_merges = match Merge::find_all_pr(pool).await { Ok(prs) => prs, Err(e) => { error!("Failed to fetch PR merges for post-login sync: {}", e); return; } }; for pr_merge in pr_merges { if !linked_workspace_ids.contains(&pr_merge.workspace_id) { continue; } upsert_pr_on_remote( client, UpsertPullRequestRequest { url: pr_merge.pr_info.url, number: pr_merge.pr_info.number as i32, status: map_pr_status(&pr_merge.pr_info.status), merged_at: pr_merge.pr_info.merged_at, merge_commit_sha: pr_merge.pr_info.merge_commit_sha, target_branch_name: pr_merge.target_branch_name, local_workspace_id: pr_merge.workspace_id, }, ) .await; } debug!("Post-login workspace sync completed"); } ================================================ FILE: crates/services/src/services/repo.rs ================================================ use std::path::{Path, PathBuf}; use db::models::repo::{Repo as RepoModel, SearchMatchType, SearchResult}; use git::{GitService, GitServiceError}; use sqlx::SqlitePool; use thiserror::Error; use utils::path::expand_tilde; use uuid::Uuid; use super::file_search::{FileSearchCache, SearchQuery}; #[derive(Debug, Error)] pub enum RepoError { #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Io(#[from] std::io::Error), #[error("Path does not exist: {0}")] PathNotFound(PathBuf), #[error("Path is not a directory: {0}")] PathNotDirectory(PathBuf), #[error("Path is not a git repository: {0}")] NotGitRepository(PathBuf), #[error("Repository not found")] NotFound, #[error("Directory already exists: {0}")] DirectoryAlreadyExists(PathBuf), #[error("Git error: {0}")] Git(#[from] GitServiceError), #[error("Invalid folder name: {0}")] InvalidFolderName(String), } pub type Result = std::result::Result; #[derive(Clone, Default)] pub struct RepoService; impl RepoService { pub fn new() -> Self { Self } pub fn validate_git_repo_path(&self, path: &Path) -> Result<()> { if !path.exists() { return Err(RepoError::PathNotFound(path.to_path_buf())); } if !path.is_dir() { return Err(RepoError::PathNotDirectory(path.to_path_buf())); } if !path.join(".git").exists() { return Err(RepoError::NotGitRepository(path.to_path_buf())); } Ok(()) } pub fn normalize_path(&self, path: &str) -> std::io::Result { std::path::absolute(expand_tilde(path)) } pub async fn register( &self, pool: &SqlitePool, path: &str, display_name: Option<&str>, ) -> Result { let normalized_path = self.normalize_path(path)?; self.validate_git_repo_path(&normalized_path)?; let name = normalized_path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "unnamed".to_string()); let display_name = display_name.unwrap_or(&name); let repo = RepoModel::find_or_create(pool, &normalized_path, display_name).await?; Ok(repo) } pub async fn find_by_id(&self, pool: &SqlitePool, repo_id: Uuid) -> Result> { let repo = RepoModel::find_by_id(pool, repo_id).await?; Ok(repo) } pub async fn get_by_id(&self, pool: &SqlitePool, repo_id: Uuid) -> Result { self.find_by_id(pool, repo_id) .await? .ok_or(RepoError::NotFound) } pub async fn init_repo( &self, pool: &SqlitePool, git: &GitService, parent_path: &str, folder_name: &str, ) -> Result { if folder_name.is_empty() || folder_name.contains('/') || folder_name.contains('\\') || folder_name == "." || folder_name == ".." { return Err(RepoError::InvalidFolderName(folder_name.to_string())); } let normalized_parent = self.normalize_path(parent_path)?; if !normalized_parent.exists() { return Err(RepoError::PathNotFound(normalized_parent)); } if !normalized_parent.is_dir() { return Err(RepoError::PathNotDirectory(normalized_parent)); } let repo_path = normalized_parent.join(folder_name); if repo_path.exists() { return Err(RepoError::DirectoryAlreadyExists(repo_path)); } git.initialize_repo_with_main_branch(&repo_path)?; let repo = RepoModel::find_or_create(pool, &repo_path, folder_name).await?; Ok(repo) } pub async fn search_files( &self, cache: &FileSearchCache, repositories: &[RepoModel], query: &SearchQuery, ) -> Result> { let query_str = query.q.trim(); if query_str.is_empty() || repositories.is_empty() { return Ok(vec![]); } // Search in parallel and prefix paths with repo name let search_futures: Vec<_> = repositories .iter() .map(|repo| { let repo_name = repo.name.clone(); let repo_path = repo.path.clone(); let mode = query.mode.clone(); let query_str = query_str.to_string(); async move { let results = cache .search_repo(&repo_path, &query_str, mode) .await .unwrap_or_else(|e| { tracing::warn!("Search failed for repo {}: {}", repo_name, e); vec![] }); (repo_name, results) } }) .collect(); let repo_results = futures::future::join_all(search_futures).await; let mut all_results: Vec = repo_results .into_iter() .flat_map(|(repo_name, results)| { results.into_iter().map(move |r| SearchResult { path: format!("{}/{}", repo_name, r.path), is_file: r.is_file, match_type: r.match_type.clone(), score: r.score, }) }) .collect(); all_results.sort_by(|a, b| { let priority = |m: &SearchMatchType| match m { SearchMatchType::FileName => 0, SearchMatchType::DirectoryName => 1, SearchMatchType::FullPath => 2, }; priority(&a.match_type) .cmp(&priority(&b.match_type)) .then_with(|| b.score.cmp(&a.score)) // Higher scores first }); all_results.truncate(10); Ok(all_results) } } ================================================ FILE: crates/services/tests/filesystem_repo_discovery.rs ================================================ #[cfg(test)] mod filesystem_tests { use std::{fs, path::Path}; use services::services::filesystem::FilesystemService; use tempfile::TempDir; /// Helper function to create a directory structure fn create_dir_structure(base: &Path, path: &str) { let full_path = base.join(path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).unwrap(); } fs::create_dir_all(&full_path).unwrap(); } /// Helper function to create a git repository (just creates .git directory) fn create_git_repo(base: &Path, path: &str) { create_dir_structure(base, path); let git_dir = base.join(path).join(".git"); fs::create_dir_all(&git_dir).unwrap(); } #[tokio::test] async fn test_list_git_repos_discovers_repos() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create test structure: // temp_dir/ // ├── project1/ (.git) // ├── project2/ (.git) // ├── regular_folder/ // └── nested/ // └── deep_repo/ (.git) create_git_repo(base_path, "project1"); create_git_repo(base_path, "project2"); create_dir_structure(base_path, "regular_folder"); let nested_path = base_path.join("nested"); fs::create_dir_all(&nested_path).unwrap(); create_git_repo(&nested_path, "deep_repo"); let filesystem_service = FilesystemService::new(); // Test discovering repos with reasonable timeouts let repos = filesystem_service .list_git_repos( Some(base_path.to_string_lossy().to_string()), 5000, // 5 second timeout 10000, // 10 second hard timeout Some(3), // max depth 3 ) .await .unwrap(); // Verify we found the git repositories let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); assert!(repo_names.contains(&"project1".to_string())); assert!(repo_names.contains(&"project2".to_string())); assert!(repo_names.contains(&"deep_repo".to_string())); assert!(!repo_names.contains(&"regular_folder".to_string())); // Verify all discovered entries are marked as git repos for repo in &repos { assert!(repo.is_git_repo); assert!(repo.is_directory); } } #[tokio::test] async fn test_list_git_repos_respects_skip_directories() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create repos in directories that should be skipped create_git_repo(base_path, "node_modules/some_repo"); create_git_repo(base_path, "target/debug_repo"); create_git_repo(base_path, "build/build_repo"); // Create repos that should be found create_git_repo(base_path, "src_repo"); create_git_repo(base_path, "my_project"); let filesystem_service = FilesystemService::new(); let repos = filesystem_service .list_git_repos( Some(base_path.to_string_lossy().to_string()), 5000, 10000, Some(3), ) .await .unwrap(); let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); // Should find the valid repos assert!(repo_names.contains(&"src_repo".to_string())); assert!(repo_names.contains(&"my_project".to_string())); // Should skip repos in ignored directories assert!(!repo_names.contains(&"some_repo".to_string())); assert!(!repo_names.contains(&"debug_repo".to_string())); assert!(!repo_names.contains(&"build_repo".to_string())); } #[tokio::test] async fn test_list_git_repos_empty_directory() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create empty directory with no git repos create_dir_structure(base_path, "empty_folder"); let filesystem_service = FilesystemService::new(); let repos = filesystem_service .list_git_repos( Some(base_path.to_string_lossy().to_string()), 5000, 10000, Some(2), ) .await .unwrap(); // Should return empty list assert!(repos.is_empty()); } #[tokio::test] async fn test_list_git_repos_nonexistent_path() { let filesystem_service = FilesystemService::new(); let result = filesystem_service .list_git_repos( Some("/nonexistent/path/that/does/not/exist".to_string()), 1000, 2000, Some(2), ) .await; // Should return an error for non-existent path assert!(result.is_err()); } #[tokio::test] async fn test_list_git_repos_with_max_depth_limit() { let temp_dir = TempDir::new().unwrap(); let base_path = temp_dir.path(); // Create nested structure deeper than max depth let deep_path = base_path.join("level1").join("level2").join("level3"); fs::create_dir_all(&deep_path).unwrap(); create_git_repo(&deep_path, "deep_repo"); create_git_repo(base_path, "shallow_repo"); let filesystem_service = FilesystemService::new(); // Search with depth limit of 2 let repos = filesystem_service .list_git_repos( Some(base_path.to_string_lossy().to_string()), 5000, 10000, Some(2), // Max depth 2 - should not find deep_repo ) .await .unwrap(); let repo_names: Vec = repos.iter().map(|r| r.name.clone()).collect(); // Should find shallow repo assert!(repo_names.contains(&"shallow_repo".to_string())); // Should not find deep repo due to depth limit assert!(!repo_names.contains(&"deep_repo".to_string())); } } ================================================ FILE: crates/tauri-app/Cargo.toml ================================================ [package] name = "vibe-kanban-tauri" version = "0.1.33" edition = "2024" [[bin]] name = "vibe-kanban-tauri" path = "src/main.rs" [build-dependencies] tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["devtools"] } tauri-plugin-shell = "2" tauri-plugin-opener = "2" tauri-plugin-updater = "2" tauri-plugin-notification = "2" server = { path = "../server" } services = { path = "../services" } utils = { path = "../utils" } async-trait = { workspace = true } tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["registry"] } serde_json = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } rustls = { workspace = true } arboard = "3.6.1" [target.'cfg(target_os = "macos")'.dependencies] objc2-web-kit = { version = "0.3", features = ["WKWebView"] } ================================================ FILE: crates/tauri-app/Info.plist ================================================ NSAppTransportSecurity NSAllowsLocalNetworking NSExceptionDomains localhost NSExceptionAllowsInsecureHTTPLoads NSIncludesSubdomains 127.0.0.1 NSExceptionAllowsInsecureHTTPLoads NSIncludesSubdomains ================================================ FILE: crates/tauri-app/build.rs ================================================ fn main() { println!("cargo:rerun-if-env-changed=SENTRY_DSN"); tauri_build::build(); #[cfg(target_os = "windows")] fix_duplicate_version_resources(); } /// Prevent CVTRES CVT1100 "duplicate resource type:VERSION" on Windows. /// /// `tauri-winres` creates `{OUT_DIR}/resource.lib` (actually a `.res` file) /// and passes it to the linker via `cargo:rustc-link-arg-bins=`. Meanwhile, /// `codex-windows-sandbox` (a transitive dep) uses the `winres` crate which /// emits `cargo:rustc-link-lib=dylib=resource` + `cargo:rustc-link-search`. /// This tells the linker to search all LIBPATHs for `resource.lib` — including /// our own OUT_DIR. The linker loads the same VERSION resource twice and CVTRES /// fails before `/FORCE:MULTIPLE` can take effect. /// /// Fix: /// 1. Copy `resource.lib` → `tauri_resource.lib` (preserving the real content) /// 2. Overwrite `resource.lib` with a valid empty `.res` file /// 3. Emit `cargo:rustc-link-arg-bins=tauri_resource.lib` for the real resource /// 4. Also overwrite `codex-windows-sandbox`'s `resource.lib` /// /// Result: the original link-arg and LIBPATH search both find empty `.res` /// stubs, while our new link-arg provides the single copy of resources. #[cfg(target_os = "windows")] fn fix_duplicate_version_resources() { let out_dir = match std::env::var("OUT_DIR") { Ok(d) => std::path::PathBuf::from(d), Err(_) => return, }; // Save the real resource under a unique name, then replace the original // with an empty stub so duplicates from LIBPATH search contribute nothing. let our_resource = out_dir.join("resource.lib"); let renamed = out_dir.join("tauri_resource.lib"); if our_resource.exists() { if std::fs::copy(&our_resource, &renamed).is_ok() { let _ = std::fs::write(&our_resource, empty_res_file()); println!("cargo:rustc-link-arg-bins={}", renamed.display()); } } // Neutralize codex-windows-sandbox's resource.lib too let build_dir = match out_dir.parent().and_then(|p| p.parent()) { Some(d) => d.to_path_buf(), None => return, }; if let Ok(entries) = std::fs::read_dir(&build_dir) { for entry in entries.flatten() { let name = entry.file_name(); if name.to_string_lossy().starts_with("codex-windows-sandbox-") { let resource_lib = entry.path().join("out").join("resource.lib"); if resource_lib.exists() { let _ = std::fs::write(&resource_lib, empty_res_file()); } } } } } /// A minimal valid `.res` file (COFF resource format) containing no resources. /// /// The `.res` format starts with a 32-byte "empty" sentinel entry: /// - DataSize: 0x00000000 (4 bytes LE) /// - HeaderSize: 0x00000020 (4 bytes LE) /// - TYPE: 0xFFFF 0x0000 (ordinal zero) /// - NAME: 0xFFFF 0x0000 (ordinal zero) /// - DataVersion, MemoryFlags, LanguageId, Version, Characteristics: all zero /// /// This is the standard header that `rc.exe` and CVTRES expect at the start of /// every `.res` file. A file containing only this header is treated as empty. #[cfg(target_os = "windows")] fn empty_res_file() -> Vec { let mut buf = Vec::with_capacity(32); buf.extend_from_slice(&0u32.to_le_bytes()); // DataSize = 0 buf.extend_from_slice(&0x20u32.to_le_bytes()); // HeaderSize = 32 buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); // TYPE: ordinal indicator buf.extend_from_slice(&0x0000u16.to_le_bytes()); // TYPE: ordinal 0 buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); // NAME: ordinal indicator buf.extend_from_slice(&0x0000u16.to_le_bytes()); // NAME: ordinal 0 buf.extend_from_slice(&0u32.to_le_bytes()); // DataVersion buf.extend_from_slice(&0u16.to_le_bytes()); // MemoryFlags buf.extend_from_slice(&0u16.to_le_bytes()); // LanguageId buf.extend_from_slice(&0u32.to_le_bytes()); // Version buf.extend_from_slice(&0u32.to_le_bytes()); // Characteristics buf } ================================================ FILE: crates/tauri-app/capabilities/default.json ================================================ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema/capability.json", "identifier": "default", "description": "Default capabilities for Vibe Kanban desktop app", "windows": ["main"], "remote": { "urls": ["http://localhost:*", "http://127.0.0.1:*"] }, "permissions": [ "core:default", "core:window:allow-start-dragging", "core:webview:allow-set-webview-zoom", "shell:allow-open", "opener:default", "updater:default", "notification:default" ] } ================================================ FILE: crates/tauri-app/gen/schemas/acl-manifests.json ================================================ {"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} ================================================ FILE: crates/tauri-app/gen/schemas/capabilities.json ================================================ {"default":{"identifier":"default","description":"Default capabilities for Vibe Kanban desktop app","remote":{"urls":["http://localhost:*","http://127.0.0.1:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:webview:allow-set-webview-zoom","shell:allow-open","opener:default","updater:default","notification:default"]}} ================================================ FILE: crates/tauri-app/gen/schemas/desktop-schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "CapabilityFile", "description": "Capability formats accepted in a capability file.", "anyOf": [ { "description": "A single capability.", "allOf": [ { "$ref": "#/definitions/Capability" } ] }, { "description": "A list of capabilities.", "type": "array", "items": { "$ref": "#/definitions/Capability" } }, { "description": "A list of capabilities.", "type": "object", "required": [ "capabilities" ], "properties": { "capabilities": { "description": "The list of capabilities.", "type": "array", "items": { "$ref": "#/definitions/Capability" } } } } ], "definitions": { "Capability": { "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", "type": "object", "required": [ "identifier", "permissions" ], "properties": { "identifier": { "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", "type": "string" }, "description": { "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", "default": "", "type": "string" }, "remote": { "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", "anyOf": [ { "$ref": "#/definitions/CapabilityRemote" }, { "type": "null" } ] }, "local": { "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", "default": true, "type": "boolean" }, "windows": { "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", "type": "array", "items": { "type": "string" } }, "webviews": { "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", "type": "array", "items": { "type": "string" } }, "permissions": { "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", "type": "array", "items": { "$ref": "#/definitions/PermissionEntry" }, "uniqueItems": true }, "platforms": { "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Target" } } } }, "CapabilityRemote": { "description": "Configuration for remote URLs that are associated with the capability.", "type": "object", "required": [ "urls" ], "properties": { "urls": { "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", "type": "array", "items": { "type": "string" } } } }, "PermissionEntry": { "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", "anyOf": [ { "description": "Reference a permission or permission set by identifier.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] }, { "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ { "if": { "properties": { "identifier": { "anyOf": [ { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", "const": "opener:default", "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" }, { "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", "type": "string", "const": "opener:allow-default-urls", "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." }, { "description": "Enables the open_path command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-path", "markdownDescription": "Enables the open_path command without any pre-configured scope." }, { "description": "Enables the open_url command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-url", "markdownDescription": "Enables the open_url command without any pre-configured scope." }, { "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:allow-reveal-item-in-dir", "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." }, { "description": "Denies the open_path command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-path", "markdownDescription": "Denies the open_path command without any pre-configured scope." }, { "description": "Denies the open_url command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-url", "markdownDescription": "Denies the open_url command without any pre-configured scope." }, { "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:deny-reveal-item-in-dir", "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." } ] } } }, "then": { "properties": { "allow": { "items": { "title": "OpenerScopeEntry", "description": "Opener scope entry.", "anyOf": [ { "type": "object", "required": [ "url" ], "properties": { "app": { "description": "An application to open this url with, for example: firefox.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "url": { "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "type": "string" } } }, { "type": "object", "required": [ "path" ], "properties": { "app": { "description": "An application to open this path with, for example: xdg-open.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "path": { "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" } } } ] } }, "deny": { "items": { "title": "OpenerScopeEntry", "description": "Opener scope entry.", "anyOf": [ { "type": "object", "required": [ "url" ], "properties": { "app": { "description": "An application to open this url with, for example: firefox.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "url": { "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "type": "string" } } }, { "type": "object", "required": [ "path" ], "properties": { "app": { "description": "An application to open this path with, for example: xdg-open.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "path": { "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" } } } ] } } } }, "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] } } }, { "if": { "properties": { "identifier": { "anyOf": [ { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", "const": "shell:default", "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" }, { "description": "Enables the execute command without any pre-configured scope.", "type": "string", "const": "shell:allow-execute", "markdownDescription": "Enables the execute command without any pre-configured scope." }, { "description": "Enables the kill command without any pre-configured scope.", "type": "string", "const": "shell:allow-kill", "markdownDescription": "Enables the kill command without any pre-configured scope." }, { "description": "Enables the open command without any pre-configured scope.", "type": "string", "const": "shell:allow-open", "markdownDescription": "Enables the open command without any pre-configured scope." }, { "description": "Enables the spawn command without any pre-configured scope.", "type": "string", "const": "shell:allow-spawn", "markdownDescription": "Enables the spawn command without any pre-configured scope." }, { "description": "Enables the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:allow-stdin-write", "markdownDescription": "Enables the stdin_write command without any pre-configured scope." }, { "description": "Denies the execute command without any pre-configured scope.", "type": "string", "const": "shell:deny-execute", "markdownDescription": "Denies the execute command without any pre-configured scope." }, { "description": "Denies the kill command without any pre-configured scope.", "type": "string", "const": "shell:deny-kill", "markdownDescription": "Denies the kill command without any pre-configured scope." }, { "description": "Denies the open command without any pre-configured scope.", "type": "string", "const": "shell:deny-open", "markdownDescription": "Denies the open command without any pre-configured scope." }, { "description": "Denies the spawn command without any pre-configured scope.", "type": "string", "const": "shell:deny-spawn", "markdownDescription": "Denies the spawn command without any pre-configured scope." }, { "description": "Denies the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:deny-stdin-write", "markdownDescription": "Denies the stdin_write command without any pre-configured scope." } ] } } }, "then": { "properties": { "allow": { "items": { "title": "ShellScopeEntry", "description": "Shell scope entry.", "anyOf": [ { "type": "object", "required": [ "cmd", "name" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "cmd": { "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" } }, "additionalProperties": false }, { "type": "object", "required": [ "name", "sidecar" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" }, "sidecar": { "description": "If this command is a sidecar command.", "type": "boolean" } }, "additionalProperties": false } ] } }, "deny": { "items": { "title": "ShellScopeEntry", "description": "Shell scope entry.", "anyOf": [ { "type": "object", "required": [ "cmd", "name" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "cmd": { "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" } }, "additionalProperties": false }, { "type": "object", "required": [ "name", "sidecar" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" }, "sidecar": { "description": "If this command is a sidecar command.", "type": "boolean" } }, "additionalProperties": false } ] } } } }, "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] } } }, { "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] }, "allow": { "description": "Data that defines what is allowed by the scope.", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Value" } }, "deny": { "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Value" } } } } ], "required": [ "identifier" ] } ] }, "Identifier": { "description": "Permission identifier", "oneOf": [ { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", "const": "core:default", "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", "type": "string", "const": "core:app:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" }, { "description": "Enables the app_hide command without any pre-configured scope.", "type": "string", "const": "core:app:allow-app-hide", "markdownDescription": "Enables the app_hide command without any pre-configured scope." }, { "description": "Enables the app_show command without any pre-configured scope.", "type": "string", "const": "core:app:allow-app-show", "markdownDescription": "Enables the app_show command without any pre-configured scope." }, { "description": "Enables the bundle_type command without any pre-configured scope.", "type": "string", "const": "core:app:allow-bundle-type", "markdownDescription": "Enables the bundle_type command without any pre-configured scope." }, { "description": "Enables the default_window_icon command without any pre-configured scope.", "type": "string", "const": "core:app:allow-default-window-icon", "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." }, { "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", "const": "core:app:allow-fetch-data-store-identifiers", "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." }, { "description": "Enables the identifier command without any pre-configured scope.", "type": "string", "const": "core:app:allow-identifier", "markdownDescription": "Enables the identifier command without any pre-configured scope." }, { "description": "Enables the name command without any pre-configured scope.", "type": "string", "const": "core:app:allow-name", "markdownDescription": "Enables the name command without any pre-configured scope." }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", "const": "core:app:allow-register-listener", "markdownDescription": "Enables the register_listener command without any pre-configured scope." }, { "description": "Enables the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-data-store", "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." }, { "description": "Enables the remove_listener command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-listener", "markdownDescription": "Enables the remove_listener command without any pre-configured scope." }, { "description": "Enables the set_app_theme command without any pre-configured scope.", "type": "string", "const": "core:app:allow-set-app-theme", "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." }, { "description": "Enables the set_dock_visibility command without any pre-configured scope.", "type": "string", "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", "const": "core:app:allow-tauri-version", "markdownDescription": "Enables the tauri_version command without any pre-configured scope." }, { "description": "Enables the version command without any pre-configured scope.", "type": "string", "const": "core:app:allow-version", "markdownDescription": "Enables the version command without any pre-configured scope." }, { "description": "Denies the app_hide command without any pre-configured scope.", "type": "string", "const": "core:app:deny-app-hide", "markdownDescription": "Denies the app_hide command without any pre-configured scope." }, { "description": "Denies the app_show command without any pre-configured scope.", "type": "string", "const": "core:app:deny-app-show", "markdownDescription": "Denies the app_show command without any pre-configured scope." }, { "description": "Denies the bundle_type command without any pre-configured scope.", "type": "string", "const": "core:app:deny-bundle-type", "markdownDescription": "Denies the bundle_type command without any pre-configured scope." }, { "description": "Denies the default_window_icon command without any pre-configured scope.", "type": "string", "const": "core:app:deny-default-window-icon", "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." }, { "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", "const": "core:app:deny-fetch-data-store-identifiers", "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." }, { "description": "Denies the identifier command without any pre-configured scope.", "type": "string", "const": "core:app:deny-identifier", "markdownDescription": "Denies the identifier command without any pre-configured scope." }, { "description": "Denies the name command without any pre-configured scope.", "type": "string", "const": "core:app:deny-name", "markdownDescription": "Denies the name command without any pre-configured scope." }, { "description": "Denies the register_listener command without any pre-configured scope.", "type": "string", "const": "core:app:deny-register-listener", "markdownDescription": "Denies the register_listener command without any pre-configured scope." }, { "description": "Denies the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-data-store", "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." }, { "description": "Denies the remove_listener command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-listener", "markdownDescription": "Denies the remove_listener command without any pre-configured scope." }, { "description": "Denies the set_app_theme command without any pre-configured scope.", "type": "string", "const": "core:app:deny-set-app-theme", "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." }, { "description": "Denies the set_dock_visibility command without any pre-configured scope.", "type": "string", "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", "const": "core:app:deny-tauri-version", "markdownDescription": "Denies the tauri_version command without any pre-configured scope." }, { "description": "Denies the version command without any pre-configured scope.", "type": "string", "const": "core:app:deny-version", "markdownDescription": "Denies the version command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", "type": "string", "const": "core:event:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" }, { "description": "Enables the emit command without any pre-configured scope.", "type": "string", "const": "core:event:allow-emit", "markdownDescription": "Enables the emit command without any pre-configured scope." }, { "description": "Enables the emit_to command without any pre-configured scope.", "type": "string", "const": "core:event:allow-emit-to", "markdownDescription": "Enables the emit_to command without any pre-configured scope." }, { "description": "Enables the listen command without any pre-configured scope.", "type": "string", "const": "core:event:allow-listen", "markdownDescription": "Enables the listen command without any pre-configured scope." }, { "description": "Enables the unlisten command without any pre-configured scope.", "type": "string", "const": "core:event:allow-unlisten", "markdownDescription": "Enables the unlisten command without any pre-configured scope." }, { "description": "Denies the emit command without any pre-configured scope.", "type": "string", "const": "core:event:deny-emit", "markdownDescription": "Denies the emit command without any pre-configured scope." }, { "description": "Denies the emit_to command without any pre-configured scope.", "type": "string", "const": "core:event:deny-emit-to", "markdownDescription": "Denies the emit_to command without any pre-configured scope." }, { "description": "Denies the listen command without any pre-configured scope.", "type": "string", "const": "core:event:deny-listen", "markdownDescription": "Denies the listen command without any pre-configured scope." }, { "description": "Denies the unlisten command without any pre-configured scope.", "type": "string", "const": "core:event:deny-unlisten", "markdownDescription": "Denies the unlisten command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", "type": "string", "const": "core:image:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" }, { "description": "Enables the from_bytes command without any pre-configured scope.", "type": "string", "const": "core:image:allow-from-bytes", "markdownDescription": "Enables the from_bytes command without any pre-configured scope." }, { "description": "Enables the from_path command without any pre-configured scope.", "type": "string", "const": "core:image:allow-from-path", "markdownDescription": "Enables the from_path command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:image:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the rgba command without any pre-configured scope.", "type": "string", "const": "core:image:allow-rgba", "markdownDescription": "Enables the rgba command without any pre-configured scope." }, { "description": "Enables the size command without any pre-configured scope.", "type": "string", "const": "core:image:allow-size", "markdownDescription": "Enables the size command without any pre-configured scope." }, { "description": "Denies the from_bytes command without any pre-configured scope.", "type": "string", "const": "core:image:deny-from-bytes", "markdownDescription": "Denies the from_bytes command without any pre-configured scope." }, { "description": "Denies the from_path command without any pre-configured scope.", "type": "string", "const": "core:image:deny-from-path", "markdownDescription": "Denies the from_path command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:image:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the rgba command without any pre-configured scope.", "type": "string", "const": "core:image:deny-rgba", "markdownDescription": "Denies the rgba command without any pre-configured scope." }, { "description": "Denies the size command without any pre-configured scope.", "type": "string", "const": "core:image:deny-size", "markdownDescription": "Denies the size command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", "type": "string", "const": "core:menu:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" }, { "description": "Enables the append command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-append", "markdownDescription": "Enables the append command without any pre-configured scope." }, { "description": "Enables the create_default command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-create-default", "markdownDescription": "Enables the create_default command without any pre-configured scope." }, { "description": "Enables the get command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-get", "markdownDescription": "Enables the get command without any pre-configured scope." }, { "description": "Enables the insert command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-insert", "markdownDescription": "Enables the insert command without any pre-configured scope." }, { "description": "Enables the is_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-is-checked", "markdownDescription": "Enables the is_checked command without any pre-configured scope." }, { "description": "Enables the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-is-enabled", "markdownDescription": "Enables the is_enabled command without any pre-configured scope." }, { "description": "Enables the items command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-items", "markdownDescription": "Enables the items command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the popup command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-popup", "markdownDescription": "Enables the popup command without any pre-configured scope." }, { "description": "Enables the prepend command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-prepend", "markdownDescription": "Enables the prepend command without any pre-configured scope." }, { "description": "Enables the remove command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-remove", "markdownDescription": "Enables the remove command without any pre-configured scope." }, { "description": "Enables the remove_at command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-remove-at", "markdownDescription": "Enables the remove_at command without any pre-configured scope." }, { "description": "Enables the set_accelerator command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-accelerator", "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." }, { "description": "Enables the set_as_app_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-app-menu", "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." }, { "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-help-menu-for-nsapp", "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." }, { "description": "Enables the set_as_window_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-window-menu", "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." }, { "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-windows-menu-for-nsapp", "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." }, { "description": "Enables the set_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-checked", "markdownDescription": "Enables the set_checked command without any pre-configured scope." }, { "description": "Enables the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-enabled", "markdownDescription": "Enables the set_enabled command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_text command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-text", "markdownDescription": "Enables the set_text command without any pre-configured scope." }, { "description": "Enables the text command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-text", "markdownDescription": "Enables the text command without any pre-configured scope." }, { "description": "Denies the append command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-append", "markdownDescription": "Denies the append command without any pre-configured scope." }, { "description": "Denies the create_default command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-create-default", "markdownDescription": "Denies the create_default command without any pre-configured scope." }, { "description": "Denies the get command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-get", "markdownDescription": "Denies the get command without any pre-configured scope." }, { "description": "Denies the insert command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-insert", "markdownDescription": "Denies the insert command without any pre-configured scope." }, { "description": "Denies the is_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-is-checked", "markdownDescription": "Denies the is_checked command without any pre-configured scope." }, { "description": "Denies the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-is-enabled", "markdownDescription": "Denies the is_enabled command without any pre-configured scope." }, { "description": "Denies the items command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-items", "markdownDescription": "Denies the items command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the popup command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-popup", "markdownDescription": "Denies the popup command without any pre-configured scope." }, { "description": "Denies the prepend command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-prepend", "markdownDescription": "Denies the prepend command without any pre-configured scope." }, { "description": "Denies the remove command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-remove", "markdownDescription": "Denies the remove command without any pre-configured scope." }, { "description": "Denies the remove_at command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-remove-at", "markdownDescription": "Denies the remove_at command without any pre-configured scope." }, { "description": "Denies the set_accelerator command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-accelerator", "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." }, { "description": "Denies the set_as_app_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-app-menu", "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." }, { "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-help-menu-for-nsapp", "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." }, { "description": "Denies the set_as_window_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-window-menu", "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." }, { "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-windows-menu-for-nsapp", "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." }, { "description": "Denies the set_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-checked", "markdownDescription": "Denies the set_checked command without any pre-configured scope." }, { "description": "Denies the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-enabled", "markdownDescription": "Denies the set_enabled command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_text command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-text", "markdownDescription": "Denies the set_text command without any pre-configured scope." }, { "description": "Denies the text command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-text", "markdownDescription": "Denies the text command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", "type": "string", "const": "core:path:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" }, { "description": "Enables the basename command without any pre-configured scope.", "type": "string", "const": "core:path:allow-basename", "markdownDescription": "Enables the basename command without any pre-configured scope." }, { "description": "Enables the dirname command without any pre-configured scope.", "type": "string", "const": "core:path:allow-dirname", "markdownDescription": "Enables the dirname command without any pre-configured scope." }, { "description": "Enables the extname command without any pre-configured scope.", "type": "string", "const": "core:path:allow-extname", "markdownDescription": "Enables the extname command without any pre-configured scope." }, { "description": "Enables the is_absolute command without any pre-configured scope.", "type": "string", "const": "core:path:allow-is-absolute", "markdownDescription": "Enables the is_absolute command without any pre-configured scope." }, { "description": "Enables the join command without any pre-configured scope.", "type": "string", "const": "core:path:allow-join", "markdownDescription": "Enables the join command without any pre-configured scope." }, { "description": "Enables the normalize command without any pre-configured scope.", "type": "string", "const": "core:path:allow-normalize", "markdownDescription": "Enables the normalize command without any pre-configured scope." }, { "description": "Enables the resolve command without any pre-configured scope.", "type": "string", "const": "core:path:allow-resolve", "markdownDescription": "Enables the resolve command without any pre-configured scope." }, { "description": "Enables the resolve_directory command without any pre-configured scope.", "type": "string", "const": "core:path:allow-resolve-directory", "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." }, { "description": "Denies the basename command without any pre-configured scope.", "type": "string", "const": "core:path:deny-basename", "markdownDescription": "Denies the basename command without any pre-configured scope." }, { "description": "Denies the dirname command without any pre-configured scope.", "type": "string", "const": "core:path:deny-dirname", "markdownDescription": "Denies the dirname command without any pre-configured scope." }, { "description": "Denies the extname command without any pre-configured scope.", "type": "string", "const": "core:path:deny-extname", "markdownDescription": "Denies the extname command without any pre-configured scope." }, { "description": "Denies the is_absolute command without any pre-configured scope.", "type": "string", "const": "core:path:deny-is-absolute", "markdownDescription": "Denies the is_absolute command without any pre-configured scope." }, { "description": "Denies the join command without any pre-configured scope.", "type": "string", "const": "core:path:deny-join", "markdownDescription": "Denies the join command without any pre-configured scope." }, { "description": "Denies the normalize command without any pre-configured scope.", "type": "string", "const": "core:path:deny-normalize", "markdownDescription": "Denies the normalize command without any pre-configured scope." }, { "description": "Denies the resolve command without any pre-configured scope.", "type": "string", "const": "core:path:deny-resolve", "markdownDescription": "Denies the resolve command without any pre-configured scope." }, { "description": "Denies the resolve_directory command without any pre-configured scope.", "type": "string", "const": "core:path:deny-resolve-directory", "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", "type": "string", "const": "core:resources:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" }, { "description": "Enables the close command without any pre-configured scope.", "type": "string", "const": "core:resources:allow-close", "markdownDescription": "Enables the close command without any pre-configured scope." }, { "description": "Denies the close command without any pre-configured scope.", "type": "string", "const": "core:resources:deny-close", "markdownDescription": "Denies the close command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-get-by-id", "markdownDescription": "Enables the get_by_id command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the remove_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-remove-by-id", "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_icon_as_template command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-menu", "markdownDescription": "Enables the set_menu command without any pre-configured scope." }, { "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-show-menu-on-left-click", "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." }, { "description": "Enables the set_temp_dir_path command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-temp-dir-path", "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." }, { "description": "Enables the set_title command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-title", "markdownDescription": "Enables the set_title command without any pre-configured scope." }, { "description": "Enables the set_tooltip command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-tooltip", "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." }, { "description": "Enables the set_visible command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-visible", "markdownDescription": "Enables the set_visible command without any pre-configured scope." }, { "description": "Denies the get_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-get-by-id", "markdownDescription": "Denies the get_by_id command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the remove_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-remove-by-id", "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_icon_as_template command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-menu", "markdownDescription": "Denies the set_menu command without any pre-configured scope." }, { "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-show-menu-on-left-click", "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." }, { "description": "Denies the set_temp_dir_path command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-temp-dir-path", "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." }, { "description": "Denies the set_title command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-title", "markdownDescription": "Denies the set_title command without any pre-configured scope." }, { "description": "Denies the set_tooltip command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-tooltip", "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." }, { "description": "Denies the set_visible command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-visible", "markdownDescription": "Denies the set_visible command without any pre-configured scope." }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", "type": "string", "const": "core:webview:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" }, { "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-clear-all-browsing-data", "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." }, { "description": "Enables the create_webview command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-create-webview", "markdownDescription": "Enables the create_webview command without any pre-configured scope." }, { "description": "Enables the create_webview_window command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-create-webview-window", "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." }, { "description": "Enables the get_all_webviews command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-get-all-webviews", "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." }, { "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-internal-toggle-devtools", "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." }, { "description": "Enables the print command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-print", "markdownDescription": "Enables the print command without any pre-configured scope." }, { "description": "Enables the reparent command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-reparent", "markdownDescription": "Enables the reparent command without any pre-configured scope." }, { "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-auto-resize", "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." }, { "description": "Enables the set_webview_background_color command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-background-color", "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." }, { "description": "Enables the set_webview_focus command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-focus", "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." }, { "description": "Enables the set_webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-position", "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." }, { "description": "Enables the set_webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-size", "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." }, { "description": "Enables the set_webview_zoom command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-zoom", "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." }, { "description": "Enables the webview_close command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-close", "markdownDescription": "Enables the webview_close command without any pre-configured scope." }, { "description": "Enables the webview_hide command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-hide", "markdownDescription": "Enables the webview_hide command without any pre-configured scope." }, { "description": "Enables the webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-position", "markdownDescription": "Enables the webview_position command without any pre-configured scope." }, { "description": "Enables the webview_show command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-show", "markdownDescription": "Enables the webview_show command without any pre-configured scope." }, { "description": "Enables the webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-size", "markdownDescription": "Enables the webview_size command without any pre-configured scope." }, { "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-clear-all-browsing-data", "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." }, { "description": "Denies the create_webview command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-create-webview", "markdownDescription": "Denies the create_webview command without any pre-configured scope." }, { "description": "Denies the create_webview_window command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-create-webview-window", "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." }, { "description": "Denies the get_all_webviews command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-get-all-webviews", "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." }, { "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-internal-toggle-devtools", "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." }, { "description": "Denies the print command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-print", "markdownDescription": "Denies the print command without any pre-configured scope." }, { "description": "Denies the reparent command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-reparent", "markdownDescription": "Denies the reparent command without any pre-configured scope." }, { "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-auto-resize", "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." }, { "description": "Denies the set_webview_background_color command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-background-color", "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." }, { "description": "Denies the set_webview_focus command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-focus", "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." }, { "description": "Denies the set_webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-position", "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." }, { "description": "Denies the set_webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-size", "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." }, { "description": "Denies the set_webview_zoom command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-zoom", "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." }, { "description": "Denies the webview_close command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-close", "markdownDescription": "Denies the webview_close command without any pre-configured scope." }, { "description": "Denies the webview_hide command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-hide", "markdownDescription": "Denies the webview_hide command without any pre-configured scope." }, { "description": "Denies the webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-position", "markdownDescription": "Denies the webview_position command without any pre-configured scope." }, { "description": "Denies the webview_show command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-show", "markdownDescription": "Denies the webview_show command without any pre-configured scope." }, { "description": "Denies the webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-size", "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" }, { "description": "Enables the available_monitors command without any pre-configured scope.", "type": "string", "const": "core:window:allow-available-monitors", "markdownDescription": "Enables the available_monitors command without any pre-configured scope." }, { "description": "Enables the center command without any pre-configured scope.", "type": "string", "const": "core:window:allow-center", "markdownDescription": "Enables the center command without any pre-configured scope." }, { "description": "Enables the close command without any pre-configured scope.", "type": "string", "const": "core:window:allow-close", "markdownDescription": "Enables the close command without any pre-configured scope." }, { "description": "Enables the create command without any pre-configured scope.", "type": "string", "const": "core:window:allow-create", "markdownDescription": "Enables the create command without any pre-configured scope." }, { "description": "Enables the current_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-current-monitor", "markdownDescription": "Enables the current_monitor command without any pre-configured scope." }, { "description": "Enables the cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-cursor-position", "markdownDescription": "Enables the cursor_position command without any pre-configured scope." }, { "description": "Enables the destroy command without any pre-configured scope.", "type": "string", "const": "core:window:allow-destroy", "markdownDescription": "Enables the destroy command without any pre-configured scope." }, { "description": "Enables the get_all_windows command without any pre-configured scope.", "type": "string", "const": "core:window:allow-get-all-windows", "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." }, { "description": "Enables the hide command without any pre-configured scope.", "type": "string", "const": "core:window:allow-hide", "markdownDescription": "Enables the hide command without any pre-configured scope." }, { "description": "Enables the inner_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-inner-position", "markdownDescription": "Enables the inner_position command without any pre-configured scope." }, { "description": "Enables the inner_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-inner-size", "markdownDescription": "Enables the inner_size command without any pre-configured scope." }, { "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-internal-toggle-maximize", "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." }, { "description": "Enables the is_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-always-on-top", "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." }, { "description": "Enables the is_closable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-closable", "markdownDescription": "Enables the is_closable command without any pre-configured scope." }, { "description": "Enables the is_decorated command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-decorated", "markdownDescription": "Enables the is_decorated command without any pre-configured scope." }, { "description": "Enables the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-enabled", "markdownDescription": "Enables the is_enabled command without any pre-configured scope." }, { "description": "Enables the is_focused command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-focused", "markdownDescription": "Enables the is_focused command without any pre-configured scope." }, { "description": "Enables the is_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-fullscreen", "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." }, { "description": "Enables the is_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-maximizable", "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." }, { "description": "Enables the is_maximized command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-maximized", "markdownDescription": "Enables the is_maximized command without any pre-configured scope." }, { "description": "Enables the is_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-minimizable", "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." }, { "description": "Enables the is_minimized command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-minimized", "markdownDescription": "Enables the is_minimized command without any pre-configured scope." }, { "description": "Enables the is_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-resizable", "markdownDescription": "Enables the is_resizable command without any pre-configured scope." }, { "description": "Enables the is_visible command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-visible", "markdownDescription": "Enables the is_visible command without any pre-configured scope." }, { "description": "Enables the maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-maximize", "markdownDescription": "Enables the maximize command without any pre-configured scope." }, { "description": "Enables the minimize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-minimize", "markdownDescription": "Enables the minimize command without any pre-configured scope." }, { "description": "Enables the monitor_from_point command without any pre-configured scope.", "type": "string", "const": "core:window:allow-monitor-from-point", "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." }, { "description": "Enables the outer_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-outer-position", "markdownDescription": "Enables the outer_position command without any pre-configured scope." }, { "description": "Enables the outer_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-outer-size", "markdownDescription": "Enables the outer_size command without any pre-configured scope." }, { "description": "Enables the primary_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-primary-monitor", "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." }, { "description": "Enables the request_user_attention command without any pre-configured scope.", "type": "string", "const": "core:window:allow-request-user-attention", "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." }, { "description": "Enables the scale_factor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-always-on-bottom", "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." }, { "description": "Enables the set_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-always-on-top", "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." }, { "description": "Enables the set_background_color command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-background-color", "markdownDescription": "Enables the set_background_color command without any pre-configured scope." }, { "description": "Enables the set_badge_count command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-badge-count", "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." }, { "description": "Enables the set_badge_label command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-badge-label", "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." }, { "description": "Enables the set_closable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-closable", "markdownDescription": "Enables the set_closable command without any pre-configured scope." }, { "description": "Enables the set_content_protected command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-content-protected", "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." }, { "description": "Enables the set_cursor_grab command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-grab", "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." }, { "description": "Enables the set_cursor_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-icon", "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." }, { "description": "Enables the set_cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-position", "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." }, { "description": "Enables the set_cursor_visible command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-visible", "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." }, { "description": "Enables the set_decorations command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-decorations", "markdownDescription": "Enables the set_decorations command without any pre-configured scope." }, { "description": "Enables the set_effects command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-effects", "markdownDescription": "Enables the set_effects command without any pre-configured scope." }, { "description": "Enables the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-enabled", "markdownDescription": "Enables the set_enabled command without any pre-configured scope." }, { "description": "Enables the set_focus command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-focus", "markdownDescription": "Enables the set_focus command without any pre-configured scope." }, { "description": "Enables the set_focusable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-focusable", "markdownDescription": "Enables the set_focusable command without any pre-configured scope." }, { "description": "Enables the set_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-fullscreen", "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-ignore-cursor-events", "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." }, { "description": "Enables the set_max_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-max-size", "markdownDescription": "Enables the set_max_size command without any pre-configured scope." }, { "description": "Enables the set_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-maximizable", "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." }, { "description": "Enables the set_min_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-min-size", "markdownDescription": "Enables the set_min_size command without any pre-configured scope." }, { "description": "Enables the set_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-minimizable", "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." }, { "description": "Enables the set_overlay_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-overlay-icon", "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." }, { "description": "Enables the set_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-position", "markdownDescription": "Enables the set_position command without any pre-configured scope." }, { "description": "Enables the set_progress_bar command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-progress-bar", "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." }, { "description": "Enables the set_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-resizable", "markdownDescription": "Enables the set_resizable command without any pre-configured scope." }, { "description": "Enables the set_shadow command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-shadow", "markdownDescription": "Enables the set_shadow command without any pre-configured scope." }, { "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-simple-fullscreen", "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." }, { "description": "Enables the set_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-size", "markdownDescription": "Enables the set_size command without any pre-configured scope." }, { "description": "Enables the set_size_constraints command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-size-constraints", "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." }, { "description": "Enables the set_skip_taskbar command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-skip-taskbar", "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." }, { "description": "Enables the set_theme command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-theme", "markdownDescription": "Enables the set_theme command without any pre-configured scope." }, { "description": "Enables the set_title command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-title", "markdownDescription": "Enables the set_title command without any pre-configured scope." }, { "description": "Enables the set_title_bar_style command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-title-bar-style", "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." }, { "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-visible-on-all-workspaces", "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." }, { "description": "Enables the show command without any pre-configured scope.", "type": "string", "const": "core:window:allow-show", "markdownDescription": "Enables the show command without any pre-configured scope." }, { "description": "Enables the start_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:allow-start-dragging", "markdownDescription": "Enables the start_dragging command without any pre-configured scope." }, { "description": "Enables the start_resize_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:allow-start-resize-dragging", "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." }, { "description": "Enables the theme command without any pre-configured scope.", "type": "string", "const": "core:window:allow-theme", "markdownDescription": "Enables the theme command without any pre-configured scope." }, { "description": "Enables the title command without any pre-configured scope.", "type": "string", "const": "core:window:allow-title", "markdownDescription": "Enables the title command without any pre-configured scope." }, { "description": "Enables the toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-toggle-maximize", "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." }, { "description": "Enables the unmaximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-unmaximize", "markdownDescription": "Enables the unmaximize command without any pre-configured scope." }, { "description": "Enables the unminimize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", "const": "core:window:deny-available-monitors", "markdownDescription": "Denies the available_monitors command without any pre-configured scope." }, { "description": "Denies the center command without any pre-configured scope.", "type": "string", "const": "core:window:deny-center", "markdownDescription": "Denies the center command without any pre-configured scope." }, { "description": "Denies the close command without any pre-configured scope.", "type": "string", "const": "core:window:deny-close", "markdownDescription": "Denies the close command without any pre-configured scope." }, { "description": "Denies the create command without any pre-configured scope.", "type": "string", "const": "core:window:deny-create", "markdownDescription": "Denies the create command without any pre-configured scope." }, { "description": "Denies the current_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-current-monitor", "markdownDescription": "Denies the current_monitor command without any pre-configured scope." }, { "description": "Denies the cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-cursor-position", "markdownDescription": "Denies the cursor_position command without any pre-configured scope." }, { "description": "Denies the destroy command without any pre-configured scope.", "type": "string", "const": "core:window:deny-destroy", "markdownDescription": "Denies the destroy command without any pre-configured scope." }, { "description": "Denies the get_all_windows command without any pre-configured scope.", "type": "string", "const": "core:window:deny-get-all-windows", "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." }, { "description": "Denies the hide command without any pre-configured scope.", "type": "string", "const": "core:window:deny-hide", "markdownDescription": "Denies the hide command without any pre-configured scope." }, { "description": "Denies the inner_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-inner-position", "markdownDescription": "Denies the inner_position command without any pre-configured scope." }, { "description": "Denies the inner_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-inner-size", "markdownDescription": "Denies the inner_size command without any pre-configured scope." }, { "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-internal-toggle-maximize", "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." }, { "description": "Denies the is_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-always-on-top", "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." }, { "description": "Denies the is_closable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-closable", "markdownDescription": "Denies the is_closable command without any pre-configured scope." }, { "description": "Denies the is_decorated command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-decorated", "markdownDescription": "Denies the is_decorated command without any pre-configured scope." }, { "description": "Denies the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-enabled", "markdownDescription": "Denies the is_enabled command without any pre-configured scope." }, { "description": "Denies the is_focused command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-focused", "markdownDescription": "Denies the is_focused command without any pre-configured scope." }, { "description": "Denies the is_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-fullscreen", "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." }, { "description": "Denies the is_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-maximizable", "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." }, { "description": "Denies the is_maximized command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-maximized", "markdownDescription": "Denies the is_maximized command without any pre-configured scope." }, { "description": "Denies the is_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-minimizable", "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." }, { "description": "Denies the is_minimized command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-minimized", "markdownDescription": "Denies the is_minimized command without any pre-configured scope." }, { "description": "Denies the is_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-resizable", "markdownDescription": "Denies the is_resizable command without any pre-configured scope." }, { "description": "Denies the is_visible command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-visible", "markdownDescription": "Denies the is_visible command without any pre-configured scope." }, { "description": "Denies the maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-maximize", "markdownDescription": "Denies the maximize command without any pre-configured scope." }, { "description": "Denies the minimize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-minimize", "markdownDescription": "Denies the minimize command without any pre-configured scope." }, { "description": "Denies the monitor_from_point command without any pre-configured scope.", "type": "string", "const": "core:window:deny-monitor-from-point", "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." }, { "description": "Denies the outer_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-outer-position", "markdownDescription": "Denies the outer_position command without any pre-configured scope." }, { "description": "Denies the outer_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-outer-size", "markdownDescription": "Denies the outer_size command without any pre-configured scope." }, { "description": "Denies the primary_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-primary-monitor", "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." }, { "description": "Denies the request_user_attention command without any pre-configured scope.", "type": "string", "const": "core:window:deny-request-user-attention", "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." }, { "description": "Denies the scale_factor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-always-on-bottom", "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." }, { "description": "Denies the set_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-always-on-top", "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." }, { "description": "Denies the set_background_color command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-background-color", "markdownDescription": "Denies the set_background_color command without any pre-configured scope." }, { "description": "Denies the set_badge_count command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-badge-count", "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." }, { "description": "Denies the set_badge_label command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-badge-label", "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." }, { "description": "Denies the set_closable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-closable", "markdownDescription": "Denies the set_closable command without any pre-configured scope." }, { "description": "Denies the set_content_protected command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-content-protected", "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." }, { "description": "Denies the set_cursor_grab command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-grab", "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." }, { "description": "Denies the set_cursor_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-icon", "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." }, { "description": "Denies the set_cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-position", "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." }, { "description": "Denies the set_cursor_visible command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-visible", "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." }, { "description": "Denies the set_decorations command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-decorations", "markdownDescription": "Denies the set_decorations command without any pre-configured scope." }, { "description": "Denies the set_effects command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-effects", "markdownDescription": "Denies the set_effects command without any pre-configured scope." }, { "description": "Denies the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-enabled", "markdownDescription": "Denies the set_enabled command without any pre-configured scope." }, { "description": "Denies the set_focus command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-focus", "markdownDescription": "Denies the set_focus command without any pre-configured scope." }, { "description": "Denies the set_focusable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-focusable", "markdownDescription": "Denies the set_focusable command without any pre-configured scope." }, { "description": "Denies the set_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-fullscreen", "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-ignore-cursor-events", "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." }, { "description": "Denies the set_max_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-max-size", "markdownDescription": "Denies the set_max_size command without any pre-configured scope." }, { "description": "Denies the set_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-maximizable", "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." }, { "description": "Denies the set_min_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-min-size", "markdownDescription": "Denies the set_min_size command without any pre-configured scope." }, { "description": "Denies the set_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-minimizable", "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." }, { "description": "Denies the set_overlay_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-overlay-icon", "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." }, { "description": "Denies the set_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-position", "markdownDescription": "Denies the set_position command without any pre-configured scope." }, { "description": "Denies the set_progress_bar command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-progress-bar", "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." }, { "description": "Denies the set_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-resizable", "markdownDescription": "Denies the set_resizable command without any pre-configured scope." }, { "description": "Denies the set_shadow command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-shadow", "markdownDescription": "Denies the set_shadow command without any pre-configured scope." }, { "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-simple-fullscreen", "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." }, { "description": "Denies the set_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-size", "markdownDescription": "Denies the set_size command without any pre-configured scope." }, { "description": "Denies the set_size_constraints command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-size-constraints", "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." }, { "description": "Denies the set_skip_taskbar command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-skip-taskbar", "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." }, { "description": "Denies the set_theme command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-theme", "markdownDescription": "Denies the set_theme command without any pre-configured scope." }, { "description": "Denies the set_title command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-title", "markdownDescription": "Denies the set_title command without any pre-configured scope." }, { "description": "Denies the set_title_bar_style command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-title-bar-style", "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." }, { "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-visible-on-all-workspaces", "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." }, { "description": "Denies the show command without any pre-configured scope.", "type": "string", "const": "core:window:deny-show", "markdownDescription": "Denies the show command without any pre-configured scope." }, { "description": "Denies the start_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:deny-start-dragging", "markdownDescription": "Denies the start_dragging command without any pre-configured scope." }, { "description": "Denies the start_resize_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:deny-start-resize-dragging", "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." }, { "description": "Denies the theme command without any pre-configured scope.", "type": "string", "const": "core:window:deny-theme", "markdownDescription": "Denies the theme command without any pre-configured scope." }, { "description": "Denies the title command without any pre-configured scope.", "type": "string", "const": "core:window:deny-title", "markdownDescription": "Denies the title command without any pre-configured scope." }, { "description": "Denies the toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-toggle-maximize", "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." }, { "description": "Denies the unmaximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-unmaximize", "markdownDescription": "Denies the unmaximize command without any pre-configured scope." }, { "description": "Denies the unminimize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", "const": "notification:default", "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" }, { "description": "Enables the batch command without any pre-configured scope.", "type": "string", "const": "notification:allow-batch", "markdownDescription": "Enables the batch command without any pre-configured scope." }, { "description": "Enables the cancel command without any pre-configured scope.", "type": "string", "const": "notification:allow-cancel", "markdownDescription": "Enables the cancel command without any pre-configured scope." }, { "description": "Enables the check_permissions command without any pre-configured scope.", "type": "string", "const": "notification:allow-check-permissions", "markdownDescription": "Enables the check_permissions command without any pre-configured scope." }, { "description": "Enables the create_channel command without any pre-configured scope.", "type": "string", "const": "notification:allow-create-channel", "markdownDescription": "Enables the create_channel command without any pre-configured scope." }, { "description": "Enables the delete_channel command without any pre-configured scope.", "type": "string", "const": "notification:allow-delete-channel", "markdownDescription": "Enables the delete_channel command without any pre-configured scope." }, { "description": "Enables the get_active command without any pre-configured scope.", "type": "string", "const": "notification:allow-get-active", "markdownDescription": "Enables the get_active command without any pre-configured scope." }, { "description": "Enables the get_pending command without any pre-configured scope.", "type": "string", "const": "notification:allow-get-pending", "markdownDescription": "Enables the get_pending command without any pre-configured scope." }, { "description": "Enables the is_permission_granted command without any pre-configured scope.", "type": "string", "const": "notification:allow-is-permission-granted", "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." }, { "description": "Enables the list_channels command without any pre-configured scope.", "type": "string", "const": "notification:allow-list-channels", "markdownDescription": "Enables the list_channels command without any pre-configured scope." }, { "description": "Enables the notify command without any pre-configured scope.", "type": "string", "const": "notification:allow-notify", "markdownDescription": "Enables the notify command without any pre-configured scope." }, { "description": "Enables the permission_state command without any pre-configured scope.", "type": "string", "const": "notification:allow-permission-state", "markdownDescription": "Enables the permission_state command without any pre-configured scope." }, { "description": "Enables the register_action_types command without any pre-configured scope.", "type": "string", "const": "notification:allow-register-action-types", "markdownDescription": "Enables the register_action_types command without any pre-configured scope." }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", "const": "notification:allow-register-listener", "markdownDescription": "Enables the register_listener command without any pre-configured scope." }, { "description": "Enables the remove_active command without any pre-configured scope.", "type": "string", "const": "notification:allow-remove-active", "markdownDescription": "Enables the remove_active command without any pre-configured scope." }, { "description": "Enables the request_permission command without any pre-configured scope.", "type": "string", "const": "notification:allow-request-permission", "markdownDescription": "Enables the request_permission command without any pre-configured scope." }, { "description": "Enables the show command without any pre-configured scope.", "type": "string", "const": "notification:allow-show", "markdownDescription": "Enables the show command without any pre-configured scope." }, { "description": "Denies the batch command without any pre-configured scope.", "type": "string", "const": "notification:deny-batch", "markdownDescription": "Denies the batch command without any pre-configured scope." }, { "description": "Denies the cancel command without any pre-configured scope.", "type": "string", "const": "notification:deny-cancel", "markdownDescription": "Denies the cancel command without any pre-configured scope." }, { "description": "Denies the check_permissions command without any pre-configured scope.", "type": "string", "const": "notification:deny-check-permissions", "markdownDescription": "Denies the check_permissions command without any pre-configured scope." }, { "description": "Denies the create_channel command without any pre-configured scope.", "type": "string", "const": "notification:deny-create-channel", "markdownDescription": "Denies the create_channel command without any pre-configured scope." }, { "description": "Denies the delete_channel command without any pre-configured scope.", "type": "string", "const": "notification:deny-delete-channel", "markdownDescription": "Denies the delete_channel command without any pre-configured scope." }, { "description": "Denies the get_active command without any pre-configured scope.", "type": "string", "const": "notification:deny-get-active", "markdownDescription": "Denies the get_active command without any pre-configured scope." }, { "description": "Denies the get_pending command without any pre-configured scope.", "type": "string", "const": "notification:deny-get-pending", "markdownDescription": "Denies the get_pending command without any pre-configured scope." }, { "description": "Denies the is_permission_granted command without any pre-configured scope.", "type": "string", "const": "notification:deny-is-permission-granted", "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." }, { "description": "Denies the list_channels command without any pre-configured scope.", "type": "string", "const": "notification:deny-list-channels", "markdownDescription": "Denies the list_channels command without any pre-configured scope." }, { "description": "Denies the notify command without any pre-configured scope.", "type": "string", "const": "notification:deny-notify", "markdownDescription": "Denies the notify command without any pre-configured scope." }, { "description": "Denies the permission_state command without any pre-configured scope.", "type": "string", "const": "notification:deny-permission-state", "markdownDescription": "Denies the permission_state command without any pre-configured scope." }, { "description": "Denies the register_action_types command without any pre-configured scope.", "type": "string", "const": "notification:deny-register-action-types", "markdownDescription": "Denies the register_action_types command without any pre-configured scope." }, { "description": "Denies the register_listener command without any pre-configured scope.", "type": "string", "const": "notification:deny-register-listener", "markdownDescription": "Denies the register_listener command without any pre-configured scope." }, { "description": "Denies the remove_active command without any pre-configured scope.", "type": "string", "const": "notification:deny-remove-active", "markdownDescription": "Denies the remove_active command without any pre-configured scope." }, { "description": "Denies the request_permission command without any pre-configured scope.", "type": "string", "const": "notification:deny-request-permission", "markdownDescription": "Denies the request_permission command without any pre-configured scope." }, { "description": "Denies the show command without any pre-configured scope.", "type": "string", "const": "notification:deny-show", "markdownDescription": "Denies the show command without any pre-configured scope." }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", "const": "opener:default", "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" }, { "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", "type": "string", "const": "opener:allow-default-urls", "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." }, { "description": "Enables the open_path command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-path", "markdownDescription": "Enables the open_path command without any pre-configured scope." }, { "description": "Enables the open_url command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-url", "markdownDescription": "Enables the open_url command without any pre-configured scope." }, { "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:allow-reveal-item-in-dir", "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." }, { "description": "Denies the open_path command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-path", "markdownDescription": "Denies the open_path command without any pre-configured scope." }, { "description": "Denies the open_url command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-url", "markdownDescription": "Denies the open_url command without any pre-configured scope." }, { "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:deny-reveal-item-in-dir", "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", "const": "shell:default", "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" }, { "description": "Enables the execute command without any pre-configured scope.", "type": "string", "const": "shell:allow-execute", "markdownDescription": "Enables the execute command without any pre-configured scope." }, { "description": "Enables the kill command without any pre-configured scope.", "type": "string", "const": "shell:allow-kill", "markdownDescription": "Enables the kill command without any pre-configured scope." }, { "description": "Enables the open command without any pre-configured scope.", "type": "string", "const": "shell:allow-open", "markdownDescription": "Enables the open command without any pre-configured scope." }, { "description": "Enables the spawn command without any pre-configured scope.", "type": "string", "const": "shell:allow-spawn", "markdownDescription": "Enables the spawn command without any pre-configured scope." }, { "description": "Enables the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:allow-stdin-write", "markdownDescription": "Enables the stdin_write command without any pre-configured scope." }, { "description": "Denies the execute command without any pre-configured scope.", "type": "string", "const": "shell:deny-execute", "markdownDescription": "Denies the execute command without any pre-configured scope." }, { "description": "Denies the kill command without any pre-configured scope.", "type": "string", "const": "shell:deny-kill", "markdownDescription": "Denies the kill command without any pre-configured scope." }, { "description": "Denies the open command without any pre-configured scope.", "type": "string", "const": "shell:deny-open", "markdownDescription": "Denies the open command without any pre-configured scope." }, { "description": "Denies the spawn command without any pre-configured scope.", "type": "string", "const": "shell:deny-spawn", "markdownDescription": "Denies the spawn command without any pre-configured scope." }, { "description": "Denies the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:deny-stdin-write", "markdownDescription": "Denies the stdin_write command without any pre-configured scope." }, { "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`", "type": "string", "const": "updater:default", "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`" }, { "description": "Enables the check command without any pre-configured scope.", "type": "string", "const": "updater:allow-check", "markdownDescription": "Enables the check command without any pre-configured scope." }, { "description": "Enables the download command without any pre-configured scope.", "type": "string", "const": "updater:allow-download", "markdownDescription": "Enables the download command without any pre-configured scope." }, { "description": "Enables the download_and_install command without any pre-configured scope.", "type": "string", "const": "updater:allow-download-and-install", "markdownDescription": "Enables the download_and_install command without any pre-configured scope." }, { "description": "Enables the install command without any pre-configured scope.", "type": "string", "const": "updater:allow-install", "markdownDescription": "Enables the install command without any pre-configured scope." }, { "description": "Denies the check command without any pre-configured scope.", "type": "string", "const": "updater:deny-check", "markdownDescription": "Denies the check command without any pre-configured scope." }, { "description": "Denies the download command without any pre-configured scope.", "type": "string", "const": "updater:deny-download", "markdownDescription": "Denies the download command without any pre-configured scope." }, { "description": "Denies the download_and_install command without any pre-configured scope.", "type": "string", "const": "updater:deny-download-and-install", "markdownDescription": "Denies the download_and_install command without any pre-configured scope." }, { "description": "Denies the install command without any pre-configured scope.", "type": "string", "const": "updater:deny-install", "markdownDescription": "Denies the install command without any pre-configured scope." } ] }, "Value": { "description": "All supported ACL values.", "anyOf": [ { "description": "Represents a null JSON value.", "type": "null" }, { "description": "Represents a [`bool`].", "type": "boolean" }, { "description": "Represents a valid ACL [`Number`].", "allOf": [ { "$ref": "#/definitions/Number" } ] }, { "description": "Represents a [`String`].", "type": "string" }, { "description": "Represents a list of other [`Value`]s.", "type": "array", "items": { "$ref": "#/definitions/Value" } }, { "description": "Represents a map of [`String`] keys to [`Value`]s.", "type": "object", "additionalProperties": { "$ref": "#/definitions/Value" } } ] }, "Number": { "description": "A valid ACL number.", "anyOf": [ { "description": "Represents an [`i64`].", "type": "integer", "format": "int64" }, { "description": "Represents a [`f64`].", "type": "number", "format": "double" } ] }, "Target": { "description": "Platform target.", "oneOf": [ { "description": "MacOS.", "type": "string", "enum": [ "macOS" ] }, { "description": "Windows.", "type": "string", "enum": [ "windows" ] }, { "description": "Linux.", "type": "string", "enum": [ "linux" ] }, { "description": "Android.", "type": "string", "enum": [ "android" ] }, { "description": "iOS.", "type": "string", "enum": [ "iOS" ] } ] }, "Application": { "description": "Opener scope application.", "anyOf": [ { "description": "Open in default application.", "type": "null" }, { "description": "If true, allow open with any application.", "type": "boolean" }, { "description": "Allow specific application to open with.", "type": "string" } ] }, "ShellScopeEntryAllowedArg": { "description": "A command argument allowed to be executed by the webview API.", "anyOf": [ { "description": "A non-configurable argument that is passed to the command in the order it was specified.", "type": "string" }, { "description": "A variable that is set while calling the command from the webview API.", "type": "object", "required": [ "validator" ], "properties": { "raw": { "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", "default": false, "type": "boolean" }, "validator": { "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", "type": "string" } }, "additionalProperties": false } ] }, "ShellScopeEntryAllowedArgs": { "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", "anyOf": [ { "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", "type": "boolean" }, { "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", "type": "array", "items": { "$ref": "#/definitions/ShellScopeEntryAllowedArg" } } ] } } } ================================================ FILE: crates/tauri-app/gen/schemas/macOS-schema.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "CapabilityFile", "description": "Capability formats accepted in a capability file.", "anyOf": [ { "description": "A single capability.", "allOf": [ { "$ref": "#/definitions/Capability" } ] }, { "description": "A list of capabilities.", "type": "array", "items": { "$ref": "#/definitions/Capability" } }, { "description": "A list of capabilities.", "type": "object", "required": [ "capabilities" ], "properties": { "capabilities": { "description": "The list of capabilities.", "type": "array", "items": { "$ref": "#/definitions/Capability" } } } } ], "definitions": { "Capability": { "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", "type": "object", "required": [ "identifier", "permissions" ], "properties": { "identifier": { "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", "type": "string" }, "description": { "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", "default": "", "type": "string" }, "remote": { "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", "anyOf": [ { "$ref": "#/definitions/CapabilityRemote" }, { "type": "null" } ] }, "local": { "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", "default": true, "type": "boolean" }, "windows": { "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", "type": "array", "items": { "type": "string" } }, "webviews": { "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", "type": "array", "items": { "type": "string" } }, "permissions": { "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", "type": "array", "items": { "$ref": "#/definitions/PermissionEntry" }, "uniqueItems": true }, "platforms": { "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Target" } } } }, "CapabilityRemote": { "description": "Configuration for remote URLs that are associated with the capability.", "type": "object", "required": [ "urls" ], "properties": { "urls": { "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", "type": "array", "items": { "type": "string" } } } }, "PermissionEntry": { "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", "anyOf": [ { "description": "Reference a permission or permission set by identifier.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] }, { "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ { "if": { "properties": { "identifier": { "anyOf": [ { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", "const": "opener:default", "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" }, { "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", "type": "string", "const": "opener:allow-default-urls", "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." }, { "description": "Enables the open_path command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-path", "markdownDescription": "Enables the open_path command without any pre-configured scope." }, { "description": "Enables the open_url command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-url", "markdownDescription": "Enables the open_url command without any pre-configured scope." }, { "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:allow-reveal-item-in-dir", "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." }, { "description": "Denies the open_path command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-path", "markdownDescription": "Denies the open_path command without any pre-configured scope." }, { "description": "Denies the open_url command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-url", "markdownDescription": "Denies the open_url command without any pre-configured scope." }, { "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:deny-reveal-item-in-dir", "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." } ] } } }, "then": { "properties": { "allow": { "items": { "title": "OpenerScopeEntry", "description": "Opener scope entry.", "anyOf": [ { "type": "object", "required": [ "url" ], "properties": { "app": { "description": "An application to open this url with, for example: firefox.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "url": { "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "type": "string" } } }, { "type": "object", "required": [ "path" ], "properties": { "app": { "description": "An application to open this path with, for example: xdg-open.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "path": { "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" } } } ] } }, "deny": { "items": { "title": "OpenerScopeEntry", "description": "Opener scope entry.", "anyOf": [ { "type": "object", "required": [ "url" ], "properties": { "app": { "description": "An application to open this url with, for example: firefox.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "url": { "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", "type": "string" } } }, { "type": "object", "required": [ "path" ], "properties": { "app": { "description": "An application to open this path with, for example: xdg-open.", "allOf": [ { "$ref": "#/definitions/Application" } ] }, "path": { "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" } } } ] } } } }, "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] } } }, { "if": { "properties": { "identifier": { "anyOf": [ { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", "const": "shell:default", "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" }, { "description": "Enables the execute command without any pre-configured scope.", "type": "string", "const": "shell:allow-execute", "markdownDescription": "Enables the execute command without any pre-configured scope." }, { "description": "Enables the kill command without any pre-configured scope.", "type": "string", "const": "shell:allow-kill", "markdownDescription": "Enables the kill command without any pre-configured scope." }, { "description": "Enables the open command without any pre-configured scope.", "type": "string", "const": "shell:allow-open", "markdownDescription": "Enables the open command without any pre-configured scope." }, { "description": "Enables the spawn command without any pre-configured scope.", "type": "string", "const": "shell:allow-spawn", "markdownDescription": "Enables the spawn command without any pre-configured scope." }, { "description": "Enables the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:allow-stdin-write", "markdownDescription": "Enables the stdin_write command without any pre-configured scope." }, { "description": "Denies the execute command without any pre-configured scope.", "type": "string", "const": "shell:deny-execute", "markdownDescription": "Denies the execute command without any pre-configured scope." }, { "description": "Denies the kill command without any pre-configured scope.", "type": "string", "const": "shell:deny-kill", "markdownDescription": "Denies the kill command without any pre-configured scope." }, { "description": "Denies the open command without any pre-configured scope.", "type": "string", "const": "shell:deny-open", "markdownDescription": "Denies the open command without any pre-configured scope." }, { "description": "Denies the spawn command without any pre-configured scope.", "type": "string", "const": "shell:deny-spawn", "markdownDescription": "Denies the spawn command without any pre-configured scope." }, { "description": "Denies the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:deny-stdin-write", "markdownDescription": "Denies the stdin_write command without any pre-configured scope." } ] } } }, "then": { "properties": { "allow": { "items": { "title": "ShellScopeEntry", "description": "Shell scope entry.", "anyOf": [ { "type": "object", "required": [ "cmd", "name" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "cmd": { "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" } }, "additionalProperties": false }, { "type": "object", "required": [ "name", "sidecar" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" }, "sidecar": { "description": "If this command is a sidecar command.", "type": "boolean" } }, "additionalProperties": false } ] } }, "deny": { "items": { "title": "ShellScopeEntry", "description": "Shell scope entry.", "anyOf": [ { "type": "object", "required": [ "cmd", "name" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "cmd": { "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", "type": "string" }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" } }, "additionalProperties": false }, { "type": "object", "required": [ "name", "sidecar" ], "properties": { "args": { "description": "The allowed arguments for the command execution.", "allOf": [ { "$ref": "#/definitions/ShellScopeEntryAllowedArgs" } ] }, "name": { "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", "type": "string" }, "sidecar": { "description": "If this command is a sidecar command.", "type": "boolean" } }, "additionalProperties": false } ] } } } }, "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] } } }, { "properties": { "identifier": { "description": "Identifier of the permission or permission set.", "allOf": [ { "$ref": "#/definitions/Identifier" } ] }, "allow": { "description": "Data that defines what is allowed by the scope.", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Value" } }, "deny": { "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", "type": [ "array", "null" ], "items": { "$ref": "#/definitions/Value" } } } } ], "required": [ "identifier" ] } ] }, "Identifier": { "description": "Permission identifier", "oneOf": [ { "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", "type": "string", "const": "core:default", "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", "type": "string", "const": "core:app:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" }, { "description": "Enables the app_hide command without any pre-configured scope.", "type": "string", "const": "core:app:allow-app-hide", "markdownDescription": "Enables the app_hide command without any pre-configured scope." }, { "description": "Enables the app_show command without any pre-configured scope.", "type": "string", "const": "core:app:allow-app-show", "markdownDescription": "Enables the app_show command without any pre-configured scope." }, { "description": "Enables the bundle_type command without any pre-configured scope.", "type": "string", "const": "core:app:allow-bundle-type", "markdownDescription": "Enables the bundle_type command without any pre-configured scope." }, { "description": "Enables the default_window_icon command without any pre-configured scope.", "type": "string", "const": "core:app:allow-default-window-icon", "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." }, { "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", "const": "core:app:allow-fetch-data-store-identifiers", "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." }, { "description": "Enables the identifier command without any pre-configured scope.", "type": "string", "const": "core:app:allow-identifier", "markdownDescription": "Enables the identifier command without any pre-configured scope." }, { "description": "Enables the name command without any pre-configured scope.", "type": "string", "const": "core:app:allow-name", "markdownDescription": "Enables the name command without any pre-configured scope." }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", "const": "core:app:allow-register-listener", "markdownDescription": "Enables the register_listener command without any pre-configured scope." }, { "description": "Enables the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-data-store", "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." }, { "description": "Enables the remove_listener command without any pre-configured scope.", "type": "string", "const": "core:app:allow-remove-listener", "markdownDescription": "Enables the remove_listener command without any pre-configured scope." }, { "description": "Enables the set_app_theme command without any pre-configured scope.", "type": "string", "const": "core:app:allow-set-app-theme", "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." }, { "description": "Enables the set_dock_visibility command without any pre-configured scope.", "type": "string", "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", "const": "core:app:allow-tauri-version", "markdownDescription": "Enables the tauri_version command without any pre-configured scope." }, { "description": "Enables the version command without any pre-configured scope.", "type": "string", "const": "core:app:allow-version", "markdownDescription": "Enables the version command without any pre-configured scope." }, { "description": "Denies the app_hide command without any pre-configured scope.", "type": "string", "const": "core:app:deny-app-hide", "markdownDescription": "Denies the app_hide command without any pre-configured scope." }, { "description": "Denies the app_show command without any pre-configured scope.", "type": "string", "const": "core:app:deny-app-show", "markdownDescription": "Denies the app_show command without any pre-configured scope." }, { "description": "Denies the bundle_type command without any pre-configured scope.", "type": "string", "const": "core:app:deny-bundle-type", "markdownDescription": "Denies the bundle_type command without any pre-configured scope." }, { "description": "Denies the default_window_icon command without any pre-configured scope.", "type": "string", "const": "core:app:deny-default-window-icon", "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." }, { "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", "const": "core:app:deny-fetch-data-store-identifiers", "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." }, { "description": "Denies the identifier command without any pre-configured scope.", "type": "string", "const": "core:app:deny-identifier", "markdownDescription": "Denies the identifier command without any pre-configured scope." }, { "description": "Denies the name command without any pre-configured scope.", "type": "string", "const": "core:app:deny-name", "markdownDescription": "Denies the name command without any pre-configured scope." }, { "description": "Denies the register_listener command without any pre-configured scope.", "type": "string", "const": "core:app:deny-register-listener", "markdownDescription": "Denies the register_listener command without any pre-configured scope." }, { "description": "Denies the remove_data_store command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-data-store", "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." }, { "description": "Denies the remove_listener command without any pre-configured scope.", "type": "string", "const": "core:app:deny-remove-listener", "markdownDescription": "Denies the remove_listener command without any pre-configured scope." }, { "description": "Denies the set_app_theme command without any pre-configured scope.", "type": "string", "const": "core:app:deny-set-app-theme", "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." }, { "description": "Denies the set_dock_visibility command without any pre-configured scope.", "type": "string", "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", "const": "core:app:deny-tauri-version", "markdownDescription": "Denies the tauri_version command without any pre-configured scope." }, { "description": "Denies the version command without any pre-configured scope.", "type": "string", "const": "core:app:deny-version", "markdownDescription": "Denies the version command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", "type": "string", "const": "core:event:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" }, { "description": "Enables the emit command without any pre-configured scope.", "type": "string", "const": "core:event:allow-emit", "markdownDescription": "Enables the emit command without any pre-configured scope." }, { "description": "Enables the emit_to command without any pre-configured scope.", "type": "string", "const": "core:event:allow-emit-to", "markdownDescription": "Enables the emit_to command without any pre-configured scope." }, { "description": "Enables the listen command without any pre-configured scope.", "type": "string", "const": "core:event:allow-listen", "markdownDescription": "Enables the listen command without any pre-configured scope." }, { "description": "Enables the unlisten command without any pre-configured scope.", "type": "string", "const": "core:event:allow-unlisten", "markdownDescription": "Enables the unlisten command without any pre-configured scope." }, { "description": "Denies the emit command without any pre-configured scope.", "type": "string", "const": "core:event:deny-emit", "markdownDescription": "Denies the emit command without any pre-configured scope." }, { "description": "Denies the emit_to command without any pre-configured scope.", "type": "string", "const": "core:event:deny-emit-to", "markdownDescription": "Denies the emit_to command without any pre-configured scope." }, { "description": "Denies the listen command without any pre-configured scope.", "type": "string", "const": "core:event:deny-listen", "markdownDescription": "Denies the listen command without any pre-configured scope." }, { "description": "Denies the unlisten command without any pre-configured scope.", "type": "string", "const": "core:event:deny-unlisten", "markdownDescription": "Denies the unlisten command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", "type": "string", "const": "core:image:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" }, { "description": "Enables the from_bytes command without any pre-configured scope.", "type": "string", "const": "core:image:allow-from-bytes", "markdownDescription": "Enables the from_bytes command without any pre-configured scope." }, { "description": "Enables the from_path command without any pre-configured scope.", "type": "string", "const": "core:image:allow-from-path", "markdownDescription": "Enables the from_path command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:image:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the rgba command without any pre-configured scope.", "type": "string", "const": "core:image:allow-rgba", "markdownDescription": "Enables the rgba command without any pre-configured scope." }, { "description": "Enables the size command without any pre-configured scope.", "type": "string", "const": "core:image:allow-size", "markdownDescription": "Enables the size command without any pre-configured scope." }, { "description": "Denies the from_bytes command without any pre-configured scope.", "type": "string", "const": "core:image:deny-from-bytes", "markdownDescription": "Denies the from_bytes command without any pre-configured scope." }, { "description": "Denies the from_path command without any pre-configured scope.", "type": "string", "const": "core:image:deny-from-path", "markdownDescription": "Denies the from_path command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:image:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the rgba command without any pre-configured scope.", "type": "string", "const": "core:image:deny-rgba", "markdownDescription": "Denies the rgba command without any pre-configured scope." }, { "description": "Denies the size command without any pre-configured scope.", "type": "string", "const": "core:image:deny-size", "markdownDescription": "Denies the size command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", "type": "string", "const": "core:menu:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" }, { "description": "Enables the append command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-append", "markdownDescription": "Enables the append command without any pre-configured scope." }, { "description": "Enables the create_default command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-create-default", "markdownDescription": "Enables the create_default command without any pre-configured scope." }, { "description": "Enables the get command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-get", "markdownDescription": "Enables the get command without any pre-configured scope." }, { "description": "Enables the insert command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-insert", "markdownDescription": "Enables the insert command without any pre-configured scope." }, { "description": "Enables the is_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-is-checked", "markdownDescription": "Enables the is_checked command without any pre-configured scope." }, { "description": "Enables the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-is-enabled", "markdownDescription": "Enables the is_enabled command without any pre-configured scope." }, { "description": "Enables the items command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-items", "markdownDescription": "Enables the items command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the popup command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-popup", "markdownDescription": "Enables the popup command without any pre-configured scope." }, { "description": "Enables the prepend command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-prepend", "markdownDescription": "Enables the prepend command without any pre-configured scope." }, { "description": "Enables the remove command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-remove", "markdownDescription": "Enables the remove command without any pre-configured scope." }, { "description": "Enables the remove_at command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-remove-at", "markdownDescription": "Enables the remove_at command without any pre-configured scope." }, { "description": "Enables the set_accelerator command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-accelerator", "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." }, { "description": "Enables the set_as_app_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-app-menu", "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." }, { "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-help-menu-for-nsapp", "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." }, { "description": "Enables the set_as_window_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-window-menu", "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." }, { "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-as-windows-menu-for-nsapp", "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." }, { "description": "Enables the set_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-checked", "markdownDescription": "Enables the set_checked command without any pre-configured scope." }, { "description": "Enables the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-enabled", "markdownDescription": "Enables the set_enabled command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_text command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-set-text", "markdownDescription": "Enables the set_text command without any pre-configured scope." }, { "description": "Enables the text command without any pre-configured scope.", "type": "string", "const": "core:menu:allow-text", "markdownDescription": "Enables the text command without any pre-configured scope." }, { "description": "Denies the append command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-append", "markdownDescription": "Denies the append command without any pre-configured scope." }, { "description": "Denies the create_default command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-create-default", "markdownDescription": "Denies the create_default command without any pre-configured scope." }, { "description": "Denies the get command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-get", "markdownDescription": "Denies the get command without any pre-configured scope." }, { "description": "Denies the insert command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-insert", "markdownDescription": "Denies the insert command without any pre-configured scope." }, { "description": "Denies the is_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-is-checked", "markdownDescription": "Denies the is_checked command without any pre-configured scope." }, { "description": "Denies the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-is-enabled", "markdownDescription": "Denies the is_enabled command without any pre-configured scope." }, { "description": "Denies the items command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-items", "markdownDescription": "Denies the items command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the popup command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-popup", "markdownDescription": "Denies the popup command without any pre-configured scope." }, { "description": "Denies the prepend command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-prepend", "markdownDescription": "Denies the prepend command without any pre-configured scope." }, { "description": "Denies the remove command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-remove", "markdownDescription": "Denies the remove command without any pre-configured scope." }, { "description": "Denies the remove_at command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-remove-at", "markdownDescription": "Denies the remove_at command without any pre-configured scope." }, { "description": "Denies the set_accelerator command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-accelerator", "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." }, { "description": "Denies the set_as_app_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-app-menu", "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." }, { "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-help-menu-for-nsapp", "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." }, { "description": "Denies the set_as_window_menu command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-window-menu", "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." }, { "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-as-windows-menu-for-nsapp", "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." }, { "description": "Denies the set_checked command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-checked", "markdownDescription": "Denies the set_checked command without any pre-configured scope." }, { "description": "Denies the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-enabled", "markdownDescription": "Denies the set_enabled command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_text command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-set-text", "markdownDescription": "Denies the set_text command without any pre-configured scope." }, { "description": "Denies the text command without any pre-configured scope.", "type": "string", "const": "core:menu:deny-text", "markdownDescription": "Denies the text command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", "type": "string", "const": "core:path:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" }, { "description": "Enables the basename command without any pre-configured scope.", "type": "string", "const": "core:path:allow-basename", "markdownDescription": "Enables the basename command without any pre-configured scope." }, { "description": "Enables the dirname command without any pre-configured scope.", "type": "string", "const": "core:path:allow-dirname", "markdownDescription": "Enables the dirname command without any pre-configured scope." }, { "description": "Enables the extname command without any pre-configured scope.", "type": "string", "const": "core:path:allow-extname", "markdownDescription": "Enables the extname command without any pre-configured scope." }, { "description": "Enables the is_absolute command without any pre-configured scope.", "type": "string", "const": "core:path:allow-is-absolute", "markdownDescription": "Enables the is_absolute command without any pre-configured scope." }, { "description": "Enables the join command without any pre-configured scope.", "type": "string", "const": "core:path:allow-join", "markdownDescription": "Enables the join command without any pre-configured scope." }, { "description": "Enables the normalize command without any pre-configured scope.", "type": "string", "const": "core:path:allow-normalize", "markdownDescription": "Enables the normalize command without any pre-configured scope." }, { "description": "Enables the resolve command without any pre-configured scope.", "type": "string", "const": "core:path:allow-resolve", "markdownDescription": "Enables the resolve command without any pre-configured scope." }, { "description": "Enables the resolve_directory command without any pre-configured scope.", "type": "string", "const": "core:path:allow-resolve-directory", "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." }, { "description": "Denies the basename command without any pre-configured scope.", "type": "string", "const": "core:path:deny-basename", "markdownDescription": "Denies the basename command without any pre-configured scope." }, { "description": "Denies the dirname command without any pre-configured scope.", "type": "string", "const": "core:path:deny-dirname", "markdownDescription": "Denies the dirname command without any pre-configured scope." }, { "description": "Denies the extname command without any pre-configured scope.", "type": "string", "const": "core:path:deny-extname", "markdownDescription": "Denies the extname command without any pre-configured scope." }, { "description": "Denies the is_absolute command without any pre-configured scope.", "type": "string", "const": "core:path:deny-is-absolute", "markdownDescription": "Denies the is_absolute command without any pre-configured scope." }, { "description": "Denies the join command without any pre-configured scope.", "type": "string", "const": "core:path:deny-join", "markdownDescription": "Denies the join command without any pre-configured scope." }, { "description": "Denies the normalize command without any pre-configured scope.", "type": "string", "const": "core:path:deny-normalize", "markdownDescription": "Denies the normalize command without any pre-configured scope." }, { "description": "Denies the resolve command without any pre-configured scope.", "type": "string", "const": "core:path:deny-resolve", "markdownDescription": "Denies the resolve command without any pre-configured scope." }, { "description": "Denies the resolve_directory command without any pre-configured scope.", "type": "string", "const": "core:path:deny-resolve-directory", "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", "type": "string", "const": "core:resources:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" }, { "description": "Enables the close command without any pre-configured scope.", "type": "string", "const": "core:resources:allow-close", "markdownDescription": "Enables the close command without any pre-configured scope." }, { "description": "Denies the close command without any pre-configured scope.", "type": "string", "const": "core:resources:deny-close", "markdownDescription": "Denies the close command without any pre-configured scope." }, { "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-get-by-id", "markdownDescription": "Enables the get_by_id command without any pre-configured scope." }, { "description": "Enables the new command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-new", "markdownDescription": "Enables the new command without any pre-configured scope." }, { "description": "Enables the remove_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-remove-by-id", "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_icon_as_template command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-menu", "markdownDescription": "Enables the set_menu command without any pre-configured scope." }, { "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-show-menu-on-left-click", "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." }, { "description": "Enables the set_temp_dir_path command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-temp-dir-path", "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." }, { "description": "Enables the set_title command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-title", "markdownDescription": "Enables the set_title command without any pre-configured scope." }, { "description": "Enables the set_tooltip command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-tooltip", "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." }, { "description": "Enables the set_visible command without any pre-configured scope.", "type": "string", "const": "core:tray:allow-set-visible", "markdownDescription": "Enables the set_visible command without any pre-configured scope." }, { "description": "Denies the get_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-get-by-id", "markdownDescription": "Denies the get_by_id command without any pre-configured scope." }, { "description": "Denies the new command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-new", "markdownDescription": "Denies the new command without any pre-configured scope." }, { "description": "Denies the remove_by_id command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-remove-by-id", "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_icon_as_template command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-menu", "markdownDescription": "Denies the set_menu command without any pre-configured scope." }, { "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-show-menu-on-left-click", "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." }, { "description": "Denies the set_temp_dir_path command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-temp-dir-path", "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." }, { "description": "Denies the set_title command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-title", "markdownDescription": "Denies the set_title command without any pre-configured scope." }, { "description": "Denies the set_tooltip command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-tooltip", "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." }, { "description": "Denies the set_visible command without any pre-configured scope.", "type": "string", "const": "core:tray:deny-set-visible", "markdownDescription": "Denies the set_visible command without any pre-configured scope." }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", "type": "string", "const": "core:webview:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" }, { "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-clear-all-browsing-data", "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." }, { "description": "Enables the create_webview command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-create-webview", "markdownDescription": "Enables the create_webview command without any pre-configured scope." }, { "description": "Enables the create_webview_window command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-create-webview-window", "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." }, { "description": "Enables the get_all_webviews command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-get-all-webviews", "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." }, { "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-internal-toggle-devtools", "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." }, { "description": "Enables the print command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-print", "markdownDescription": "Enables the print command without any pre-configured scope." }, { "description": "Enables the reparent command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-reparent", "markdownDescription": "Enables the reparent command without any pre-configured scope." }, { "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-auto-resize", "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." }, { "description": "Enables the set_webview_background_color command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-background-color", "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." }, { "description": "Enables the set_webview_focus command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-focus", "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." }, { "description": "Enables the set_webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-position", "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." }, { "description": "Enables the set_webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-size", "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." }, { "description": "Enables the set_webview_zoom command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-set-webview-zoom", "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." }, { "description": "Enables the webview_close command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-close", "markdownDescription": "Enables the webview_close command without any pre-configured scope." }, { "description": "Enables the webview_hide command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-hide", "markdownDescription": "Enables the webview_hide command without any pre-configured scope." }, { "description": "Enables the webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-position", "markdownDescription": "Enables the webview_position command without any pre-configured scope." }, { "description": "Enables the webview_show command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-show", "markdownDescription": "Enables the webview_show command without any pre-configured scope." }, { "description": "Enables the webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:allow-webview-size", "markdownDescription": "Enables the webview_size command without any pre-configured scope." }, { "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-clear-all-browsing-data", "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." }, { "description": "Denies the create_webview command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-create-webview", "markdownDescription": "Denies the create_webview command without any pre-configured scope." }, { "description": "Denies the create_webview_window command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-create-webview-window", "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." }, { "description": "Denies the get_all_webviews command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-get-all-webviews", "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." }, { "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-internal-toggle-devtools", "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." }, { "description": "Denies the print command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-print", "markdownDescription": "Denies the print command without any pre-configured scope." }, { "description": "Denies the reparent command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-reparent", "markdownDescription": "Denies the reparent command without any pre-configured scope." }, { "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-auto-resize", "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." }, { "description": "Denies the set_webview_background_color command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-background-color", "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." }, { "description": "Denies the set_webview_focus command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-focus", "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." }, { "description": "Denies the set_webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-position", "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." }, { "description": "Denies the set_webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-size", "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." }, { "description": "Denies the set_webview_zoom command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-set-webview-zoom", "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." }, { "description": "Denies the webview_close command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-close", "markdownDescription": "Denies the webview_close command without any pre-configured scope." }, { "description": "Denies the webview_hide command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-hide", "markdownDescription": "Denies the webview_hide command without any pre-configured scope." }, { "description": "Denies the webview_position command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-position", "markdownDescription": "Denies the webview_position command without any pre-configured scope." }, { "description": "Denies the webview_show command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-show", "markdownDescription": "Denies the webview_show command without any pre-configured scope." }, { "description": "Denies the webview_size command without any pre-configured scope.", "type": "string", "const": "core:webview:deny-webview-size", "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" }, { "description": "Enables the available_monitors command without any pre-configured scope.", "type": "string", "const": "core:window:allow-available-monitors", "markdownDescription": "Enables the available_monitors command without any pre-configured scope." }, { "description": "Enables the center command without any pre-configured scope.", "type": "string", "const": "core:window:allow-center", "markdownDescription": "Enables the center command without any pre-configured scope." }, { "description": "Enables the close command without any pre-configured scope.", "type": "string", "const": "core:window:allow-close", "markdownDescription": "Enables the close command without any pre-configured scope." }, { "description": "Enables the create command without any pre-configured scope.", "type": "string", "const": "core:window:allow-create", "markdownDescription": "Enables the create command without any pre-configured scope." }, { "description": "Enables the current_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-current-monitor", "markdownDescription": "Enables the current_monitor command without any pre-configured scope." }, { "description": "Enables the cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-cursor-position", "markdownDescription": "Enables the cursor_position command without any pre-configured scope." }, { "description": "Enables the destroy command without any pre-configured scope.", "type": "string", "const": "core:window:allow-destroy", "markdownDescription": "Enables the destroy command without any pre-configured scope." }, { "description": "Enables the get_all_windows command without any pre-configured scope.", "type": "string", "const": "core:window:allow-get-all-windows", "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." }, { "description": "Enables the hide command without any pre-configured scope.", "type": "string", "const": "core:window:allow-hide", "markdownDescription": "Enables the hide command without any pre-configured scope." }, { "description": "Enables the inner_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-inner-position", "markdownDescription": "Enables the inner_position command without any pre-configured scope." }, { "description": "Enables the inner_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-inner-size", "markdownDescription": "Enables the inner_size command without any pre-configured scope." }, { "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-internal-toggle-maximize", "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." }, { "description": "Enables the is_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-always-on-top", "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." }, { "description": "Enables the is_closable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-closable", "markdownDescription": "Enables the is_closable command without any pre-configured scope." }, { "description": "Enables the is_decorated command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-decorated", "markdownDescription": "Enables the is_decorated command without any pre-configured scope." }, { "description": "Enables the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-enabled", "markdownDescription": "Enables the is_enabled command without any pre-configured scope." }, { "description": "Enables the is_focused command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-focused", "markdownDescription": "Enables the is_focused command without any pre-configured scope." }, { "description": "Enables the is_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-fullscreen", "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." }, { "description": "Enables the is_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-maximizable", "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." }, { "description": "Enables the is_maximized command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-maximized", "markdownDescription": "Enables the is_maximized command without any pre-configured scope." }, { "description": "Enables the is_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-minimizable", "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." }, { "description": "Enables the is_minimized command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-minimized", "markdownDescription": "Enables the is_minimized command without any pre-configured scope." }, { "description": "Enables the is_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-resizable", "markdownDescription": "Enables the is_resizable command without any pre-configured scope." }, { "description": "Enables the is_visible command without any pre-configured scope.", "type": "string", "const": "core:window:allow-is-visible", "markdownDescription": "Enables the is_visible command without any pre-configured scope." }, { "description": "Enables the maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-maximize", "markdownDescription": "Enables the maximize command without any pre-configured scope." }, { "description": "Enables the minimize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-minimize", "markdownDescription": "Enables the minimize command without any pre-configured scope." }, { "description": "Enables the monitor_from_point command without any pre-configured scope.", "type": "string", "const": "core:window:allow-monitor-from-point", "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." }, { "description": "Enables the outer_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-outer-position", "markdownDescription": "Enables the outer_position command without any pre-configured scope." }, { "description": "Enables the outer_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-outer-size", "markdownDescription": "Enables the outer_size command without any pre-configured scope." }, { "description": "Enables the primary_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-primary-monitor", "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." }, { "description": "Enables the request_user_attention command without any pre-configured scope.", "type": "string", "const": "core:window:allow-request-user-attention", "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." }, { "description": "Enables the scale_factor command without any pre-configured scope.", "type": "string", "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-always-on-bottom", "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." }, { "description": "Enables the set_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-always-on-top", "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." }, { "description": "Enables the set_background_color command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-background-color", "markdownDescription": "Enables the set_background_color command without any pre-configured scope." }, { "description": "Enables the set_badge_count command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-badge-count", "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." }, { "description": "Enables the set_badge_label command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-badge-label", "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." }, { "description": "Enables the set_closable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-closable", "markdownDescription": "Enables the set_closable command without any pre-configured scope." }, { "description": "Enables the set_content_protected command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-content-protected", "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." }, { "description": "Enables the set_cursor_grab command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-grab", "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." }, { "description": "Enables the set_cursor_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-icon", "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." }, { "description": "Enables the set_cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-position", "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." }, { "description": "Enables the set_cursor_visible command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-cursor-visible", "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." }, { "description": "Enables the set_decorations command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-decorations", "markdownDescription": "Enables the set_decorations command without any pre-configured scope." }, { "description": "Enables the set_effects command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-effects", "markdownDescription": "Enables the set_effects command without any pre-configured scope." }, { "description": "Enables the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-enabled", "markdownDescription": "Enables the set_enabled command without any pre-configured scope." }, { "description": "Enables the set_focus command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-focus", "markdownDescription": "Enables the set_focus command without any pre-configured scope." }, { "description": "Enables the set_focusable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-focusable", "markdownDescription": "Enables the set_focusable command without any pre-configured scope." }, { "description": "Enables the set_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-fullscreen", "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-icon", "markdownDescription": "Enables the set_icon command without any pre-configured scope." }, { "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-ignore-cursor-events", "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." }, { "description": "Enables the set_max_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-max-size", "markdownDescription": "Enables the set_max_size command without any pre-configured scope." }, { "description": "Enables the set_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-maximizable", "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." }, { "description": "Enables the set_min_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-min-size", "markdownDescription": "Enables the set_min_size command without any pre-configured scope." }, { "description": "Enables the set_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-minimizable", "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." }, { "description": "Enables the set_overlay_icon command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-overlay-icon", "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." }, { "description": "Enables the set_position command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-position", "markdownDescription": "Enables the set_position command without any pre-configured scope." }, { "description": "Enables the set_progress_bar command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-progress-bar", "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." }, { "description": "Enables the set_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-resizable", "markdownDescription": "Enables the set_resizable command without any pre-configured scope." }, { "description": "Enables the set_shadow command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-shadow", "markdownDescription": "Enables the set_shadow command without any pre-configured scope." }, { "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-simple-fullscreen", "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." }, { "description": "Enables the set_size command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-size", "markdownDescription": "Enables the set_size command without any pre-configured scope." }, { "description": "Enables the set_size_constraints command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-size-constraints", "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." }, { "description": "Enables the set_skip_taskbar command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-skip-taskbar", "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." }, { "description": "Enables the set_theme command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-theme", "markdownDescription": "Enables the set_theme command without any pre-configured scope." }, { "description": "Enables the set_title command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-title", "markdownDescription": "Enables the set_title command without any pre-configured scope." }, { "description": "Enables the set_title_bar_style command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-title-bar-style", "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." }, { "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", "type": "string", "const": "core:window:allow-set-visible-on-all-workspaces", "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." }, { "description": "Enables the show command without any pre-configured scope.", "type": "string", "const": "core:window:allow-show", "markdownDescription": "Enables the show command without any pre-configured scope." }, { "description": "Enables the start_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:allow-start-dragging", "markdownDescription": "Enables the start_dragging command without any pre-configured scope." }, { "description": "Enables the start_resize_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:allow-start-resize-dragging", "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." }, { "description": "Enables the theme command without any pre-configured scope.", "type": "string", "const": "core:window:allow-theme", "markdownDescription": "Enables the theme command without any pre-configured scope." }, { "description": "Enables the title command without any pre-configured scope.", "type": "string", "const": "core:window:allow-title", "markdownDescription": "Enables the title command without any pre-configured scope." }, { "description": "Enables the toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-toggle-maximize", "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." }, { "description": "Enables the unmaximize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-unmaximize", "markdownDescription": "Enables the unmaximize command without any pre-configured scope." }, { "description": "Enables the unminimize command without any pre-configured scope.", "type": "string", "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", "const": "core:window:deny-available-monitors", "markdownDescription": "Denies the available_monitors command without any pre-configured scope." }, { "description": "Denies the center command without any pre-configured scope.", "type": "string", "const": "core:window:deny-center", "markdownDescription": "Denies the center command without any pre-configured scope." }, { "description": "Denies the close command without any pre-configured scope.", "type": "string", "const": "core:window:deny-close", "markdownDescription": "Denies the close command without any pre-configured scope." }, { "description": "Denies the create command without any pre-configured scope.", "type": "string", "const": "core:window:deny-create", "markdownDescription": "Denies the create command without any pre-configured scope." }, { "description": "Denies the current_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-current-monitor", "markdownDescription": "Denies the current_monitor command without any pre-configured scope." }, { "description": "Denies the cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-cursor-position", "markdownDescription": "Denies the cursor_position command without any pre-configured scope." }, { "description": "Denies the destroy command without any pre-configured scope.", "type": "string", "const": "core:window:deny-destroy", "markdownDescription": "Denies the destroy command without any pre-configured scope." }, { "description": "Denies the get_all_windows command without any pre-configured scope.", "type": "string", "const": "core:window:deny-get-all-windows", "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." }, { "description": "Denies the hide command without any pre-configured scope.", "type": "string", "const": "core:window:deny-hide", "markdownDescription": "Denies the hide command without any pre-configured scope." }, { "description": "Denies the inner_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-inner-position", "markdownDescription": "Denies the inner_position command without any pre-configured scope." }, { "description": "Denies the inner_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-inner-size", "markdownDescription": "Denies the inner_size command without any pre-configured scope." }, { "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-internal-toggle-maximize", "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." }, { "description": "Denies the is_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-always-on-top", "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." }, { "description": "Denies the is_closable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-closable", "markdownDescription": "Denies the is_closable command without any pre-configured scope." }, { "description": "Denies the is_decorated command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-decorated", "markdownDescription": "Denies the is_decorated command without any pre-configured scope." }, { "description": "Denies the is_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-enabled", "markdownDescription": "Denies the is_enabled command without any pre-configured scope." }, { "description": "Denies the is_focused command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-focused", "markdownDescription": "Denies the is_focused command without any pre-configured scope." }, { "description": "Denies the is_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-fullscreen", "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." }, { "description": "Denies the is_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-maximizable", "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." }, { "description": "Denies the is_maximized command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-maximized", "markdownDescription": "Denies the is_maximized command without any pre-configured scope." }, { "description": "Denies the is_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-minimizable", "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." }, { "description": "Denies the is_minimized command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-minimized", "markdownDescription": "Denies the is_minimized command without any pre-configured scope." }, { "description": "Denies the is_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-resizable", "markdownDescription": "Denies the is_resizable command without any pre-configured scope." }, { "description": "Denies the is_visible command without any pre-configured scope.", "type": "string", "const": "core:window:deny-is-visible", "markdownDescription": "Denies the is_visible command without any pre-configured scope." }, { "description": "Denies the maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-maximize", "markdownDescription": "Denies the maximize command without any pre-configured scope." }, { "description": "Denies the minimize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-minimize", "markdownDescription": "Denies the minimize command without any pre-configured scope." }, { "description": "Denies the monitor_from_point command without any pre-configured scope.", "type": "string", "const": "core:window:deny-monitor-from-point", "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." }, { "description": "Denies the outer_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-outer-position", "markdownDescription": "Denies the outer_position command without any pre-configured scope." }, { "description": "Denies the outer_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-outer-size", "markdownDescription": "Denies the outer_size command without any pre-configured scope." }, { "description": "Denies the primary_monitor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-primary-monitor", "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." }, { "description": "Denies the request_user_attention command without any pre-configured scope.", "type": "string", "const": "core:window:deny-request-user-attention", "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." }, { "description": "Denies the scale_factor command without any pre-configured scope.", "type": "string", "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-always-on-bottom", "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." }, { "description": "Denies the set_always_on_top command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-always-on-top", "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." }, { "description": "Denies the set_background_color command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-background-color", "markdownDescription": "Denies the set_background_color command without any pre-configured scope." }, { "description": "Denies the set_badge_count command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-badge-count", "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." }, { "description": "Denies the set_badge_label command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-badge-label", "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." }, { "description": "Denies the set_closable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-closable", "markdownDescription": "Denies the set_closable command without any pre-configured scope." }, { "description": "Denies the set_content_protected command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-content-protected", "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." }, { "description": "Denies the set_cursor_grab command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-grab", "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." }, { "description": "Denies the set_cursor_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-icon", "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." }, { "description": "Denies the set_cursor_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-position", "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." }, { "description": "Denies the set_cursor_visible command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-cursor-visible", "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." }, { "description": "Denies the set_decorations command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-decorations", "markdownDescription": "Denies the set_decorations command without any pre-configured scope." }, { "description": "Denies the set_effects command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-effects", "markdownDescription": "Denies the set_effects command without any pre-configured scope." }, { "description": "Denies the set_enabled command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-enabled", "markdownDescription": "Denies the set_enabled command without any pre-configured scope." }, { "description": "Denies the set_focus command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-focus", "markdownDescription": "Denies the set_focus command without any pre-configured scope." }, { "description": "Denies the set_focusable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-focusable", "markdownDescription": "Denies the set_focusable command without any pre-configured scope." }, { "description": "Denies the set_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-fullscreen", "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-icon", "markdownDescription": "Denies the set_icon command without any pre-configured scope." }, { "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-ignore-cursor-events", "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." }, { "description": "Denies the set_max_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-max-size", "markdownDescription": "Denies the set_max_size command without any pre-configured scope." }, { "description": "Denies the set_maximizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-maximizable", "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." }, { "description": "Denies the set_min_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-min-size", "markdownDescription": "Denies the set_min_size command without any pre-configured scope." }, { "description": "Denies the set_minimizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-minimizable", "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." }, { "description": "Denies the set_overlay_icon command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-overlay-icon", "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." }, { "description": "Denies the set_position command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-position", "markdownDescription": "Denies the set_position command without any pre-configured scope." }, { "description": "Denies the set_progress_bar command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-progress-bar", "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." }, { "description": "Denies the set_resizable command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-resizable", "markdownDescription": "Denies the set_resizable command without any pre-configured scope." }, { "description": "Denies the set_shadow command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-shadow", "markdownDescription": "Denies the set_shadow command without any pre-configured scope." }, { "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-simple-fullscreen", "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." }, { "description": "Denies the set_size command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-size", "markdownDescription": "Denies the set_size command without any pre-configured scope." }, { "description": "Denies the set_size_constraints command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-size-constraints", "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." }, { "description": "Denies the set_skip_taskbar command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-skip-taskbar", "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." }, { "description": "Denies the set_theme command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-theme", "markdownDescription": "Denies the set_theme command without any pre-configured scope." }, { "description": "Denies the set_title command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-title", "markdownDescription": "Denies the set_title command without any pre-configured scope." }, { "description": "Denies the set_title_bar_style command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-title-bar-style", "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." }, { "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", "type": "string", "const": "core:window:deny-set-visible-on-all-workspaces", "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." }, { "description": "Denies the show command without any pre-configured scope.", "type": "string", "const": "core:window:deny-show", "markdownDescription": "Denies the show command without any pre-configured scope." }, { "description": "Denies the start_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:deny-start-dragging", "markdownDescription": "Denies the start_dragging command without any pre-configured scope." }, { "description": "Denies the start_resize_dragging command without any pre-configured scope.", "type": "string", "const": "core:window:deny-start-resize-dragging", "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." }, { "description": "Denies the theme command without any pre-configured scope.", "type": "string", "const": "core:window:deny-theme", "markdownDescription": "Denies the theme command without any pre-configured scope." }, { "description": "Denies the title command without any pre-configured scope.", "type": "string", "const": "core:window:deny-title", "markdownDescription": "Denies the title command without any pre-configured scope." }, { "description": "Denies the toggle_maximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-toggle-maximize", "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." }, { "description": "Denies the unmaximize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-unmaximize", "markdownDescription": "Denies the unmaximize command without any pre-configured scope." }, { "description": "Denies the unminimize command without any pre-configured scope.", "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", "const": "notification:default", "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" }, { "description": "Enables the batch command without any pre-configured scope.", "type": "string", "const": "notification:allow-batch", "markdownDescription": "Enables the batch command without any pre-configured scope." }, { "description": "Enables the cancel command without any pre-configured scope.", "type": "string", "const": "notification:allow-cancel", "markdownDescription": "Enables the cancel command without any pre-configured scope." }, { "description": "Enables the check_permissions command without any pre-configured scope.", "type": "string", "const": "notification:allow-check-permissions", "markdownDescription": "Enables the check_permissions command without any pre-configured scope." }, { "description": "Enables the create_channel command without any pre-configured scope.", "type": "string", "const": "notification:allow-create-channel", "markdownDescription": "Enables the create_channel command without any pre-configured scope." }, { "description": "Enables the delete_channel command without any pre-configured scope.", "type": "string", "const": "notification:allow-delete-channel", "markdownDescription": "Enables the delete_channel command without any pre-configured scope." }, { "description": "Enables the get_active command without any pre-configured scope.", "type": "string", "const": "notification:allow-get-active", "markdownDescription": "Enables the get_active command without any pre-configured scope." }, { "description": "Enables the get_pending command without any pre-configured scope.", "type": "string", "const": "notification:allow-get-pending", "markdownDescription": "Enables the get_pending command without any pre-configured scope." }, { "description": "Enables the is_permission_granted command without any pre-configured scope.", "type": "string", "const": "notification:allow-is-permission-granted", "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." }, { "description": "Enables the list_channels command without any pre-configured scope.", "type": "string", "const": "notification:allow-list-channels", "markdownDescription": "Enables the list_channels command without any pre-configured scope." }, { "description": "Enables the notify command without any pre-configured scope.", "type": "string", "const": "notification:allow-notify", "markdownDescription": "Enables the notify command without any pre-configured scope." }, { "description": "Enables the permission_state command without any pre-configured scope.", "type": "string", "const": "notification:allow-permission-state", "markdownDescription": "Enables the permission_state command without any pre-configured scope." }, { "description": "Enables the register_action_types command without any pre-configured scope.", "type": "string", "const": "notification:allow-register-action-types", "markdownDescription": "Enables the register_action_types command without any pre-configured scope." }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", "const": "notification:allow-register-listener", "markdownDescription": "Enables the register_listener command without any pre-configured scope." }, { "description": "Enables the remove_active command without any pre-configured scope.", "type": "string", "const": "notification:allow-remove-active", "markdownDescription": "Enables the remove_active command without any pre-configured scope." }, { "description": "Enables the request_permission command without any pre-configured scope.", "type": "string", "const": "notification:allow-request-permission", "markdownDescription": "Enables the request_permission command without any pre-configured scope." }, { "description": "Enables the show command without any pre-configured scope.", "type": "string", "const": "notification:allow-show", "markdownDescription": "Enables the show command without any pre-configured scope." }, { "description": "Denies the batch command without any pre-configured scope.", "type": "string", "const": "notification:deny-batch", "markdownDescription": "Denies the batch command without any pre-configured scope." }, { "description": "Denies the cancel command without any pre-configured scope.", "type": "string", "const": "notification:deny-cancel", "markdownDescription": "Denies the cancel command without any pre-configured scope." }, { "description": "Denies the check_permissions command without any pre-configured scope.", "type": "string", "const": "notification:deny-check-permissions", "markdownDescription": "Denies the check_permissions command without any pre-configured scope." }, { "description": "Denies the create_channel command without any pre-configured scope.", "type": "string", "const": "notification:deny-create-channel", "markdownDescription": "Denies the create_channel command without any pre-configured scope." }, { "description": "Denies the delete_channel command without any pre-configured scope.", "type": "string", "const": "notification:deny-delete-channel", "markdownDescription": "Denies the delete_channel command without any pre-configured scope." }, { "description": "Denies the get_active command without any pre-configured scope.", "type": "string", "const": "notification:deny-get-active", "markdownDescription": "Denies the get_active command without any pre-configured scope." }, { "description": "Denies the get_pending command without any pre-configured scope.", "type": "string", "const": "notification:deny-get-pending", "markdownDescription": "Denies the get_pending command without any pre-configured scope." }, { "description": "Denies the is_permission_granted command without any pre-configured scope.", "type": "string", "const": "notification:deny-is-permission-granted", "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." }, { "description": "Denies the list_channels command without any pre-configured scope.", "type": "string", "const": "notification:deny-list-channels", "markdownDescription": "Denies the list_channels command without any pre-configured scope." }, { "description": "Denies the notify command without any pre-configured scope.", "type": "string", "const": "notification:deny-notify", "markdownDescription": "Denies the notify command without any pre-configured scope." }, { "description": "Denies the permission_state command without any pre-configured scope.", "type": "string", "const": "notification:deny-permission-state", "markdownDescription": "Denies the permission_state command without any pre-configured scope." }, { "description": "Denies the register_action_types command without any pre-configured scope.", "type": "string", "const": "notification:deny-register-action-types", "markdownDescription": "Denies the register_action_types command without any pre-configured scope." }, { "description": "Denies the register_listener command without any pre-configured scope.", "type": "string", "const": "notification:deny-register-listener", "markdownDescription": "Denies the register_listener command without any pre-configured scope." }, { "description": "Denies the remove_active command without any pre-configured scope.", "type": "string", "const": "notification:deny-remove-active", "markdownDescription": "Denies the remove_active command without any pre-configured scope." }, { "description": "Denies the request_permission command without any pre-configured scope.", "type": "string", "const": "notification:deny-request-permission", "markdownDescription": "Denies the request_permission command without any pre-configured scope." }, { "description": "Denies the show command without any pre-configured scope.", "type": "string", "const": "notification:deny-show", "markdownDescription": "Denies the show command without any pre-configured scope." }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", "const": "opener:default", "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" }, { "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", "type": "string", "const": "opener:allow-default-urls", "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." }, { "description": "Enables the open_path command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-path", "markdownDescription": "Enables the open_path command without any pre-configured scope." }, { "description": "Enables the open_url command without any pre-configured scope.", "type": "string", "const": "opener:allow-open-url", "markdownDescription": "Enables the open_url command without any pre-configured scope." }, { "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:allow-reveal-item-in-dir", "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." }, { "description": "Denies the open_path command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-path", "markdownDescription": "Denies the open_path command without any pre-configured scope." }, { "description": "Denies the open_url command without any pre-configured scope.", "type": "string", "const": "opener:deny-open-url", "markdownDescription": "Denies the open_url command without any pre-configured scope." }, { "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", "type": "string", "const": "opener:deny-reveal-item-in-dir", "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." }, { "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "type": "string", "const": "shell:default", "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" }, { "description": "Enables the execute command without any pre-configured scope.", "type": "string", "const": "shell:allow-execute", "markdownDescription": "Enables the execute command without any pre-configured scope." }, { "description": "Enables the kill command without any pre-configured scope.", "type": "string", "const": "shell:allow-kill", "markdownDescription": "Enables the kill command without any pre-configured scope." }, { "description": "Enables the open command without any pre-configured scope.", "type": "string", "const": "shell:allow-open", "markdownDescription": "Enables the open command without any pre-configured scope." }, { "description": "Enables the spawn command without any pre-configured scope.", "type": "string", "const": "shell:allow-spawn", "markdownDescription": "Enables the spawn command without any pre-configured scope." }, { "description": "Enables the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:allow-stdin-write", "markdownDescription": "Enables the stdin_write command without any pre-configured scope." }, { "description": "Denies the execute command without any pre-configured scope.", "type": "string", "const": "shell:deny-execute", "markdownDescription": "Denies the execute command without any pre-configured scope." }, { "description": "Denies the kill command without any pre-configured scope.", "type": "string", "const": "shell:deny-kill", "markdownDescription": "Denies the kill command without any pre-configured scope." }, { "description": "Denies the open command without any pre-configured scope.", "type": "string", "const": "shell:deny-open", "markdownDescription": "Denies the open command without any pre-configured scope." }, { "description": "Denies the spawn command without any pre-configured scope.", "type": "string", "const": "shell:deny-spawn", "markdownDescription": "Denies the spawn command without any pre-configured scope." }, { "description": "Denies the stdin_write command without any pre-configured scope.", "type": "string", "const": "shell:deny-stdin-write", "markdownDescription": "Denies the stdin_write command without any pre-configured scope." }, { "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`", "type": "string", "const": "updater:default", "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`" }, { "description": "Enables the check command without any pre-configured scope.", "type": "string", "const": "updater:allow-check", "markdownDescription": "Enables the check command without any pre-configured scope." }, { "description": "Enables the download command without any pre-configured scope.", "type": "string", "const": "updater:allow-download", "markdownDescription": "Enables the download command without any pre-configured scope." }, { "description": "Enables the download_and_install command without any pre-configured scope.", "type": "string", "const": "updater:allow-download-and-install", "markdownDescription": "Enables the download_and_install command without any pre-configured scope." }, { "description": "Enables the install command without any pre-configured scope.", "type": "string", "const": "updater:allow-install", "markdownDescription": "Enables the install command without any pre-configured scope." }, { "description": "Denies the check command without any pre-configured scope.", "type": "string", "const": "updater:deny-check", "markdownDescription": "Denies the check command without any pre-configured scope." }, { "description": "Denies the download command without any pre-configured scope.", "type": "string", "const": "updater:deny-download", "markdownDescription": "Denies the download command without any pre-configured scope." }, { "description": "Denies the download_and_install command without any pre-configured scope.", "type": "string", "const": "updater:deny-download-and-install", "markdownDescription": "Denies the download_and_install command without any pre-configured scope." }, { "description": "Denies the install command without any pre-configured scope.", "type": "string", "const": "updater:deny-install", "markdownDescription": "Denies the install command without any pre-configured scope." } ] }, "Value": { "description": "All supported ACL values.", "anyOf": [ { "description": "Represents a null JSON value.", "type": "null" }, { "description": "Represents a [`bool`].", "type": "boolean" }, { "description": "Represents a valid ACL [`Number`].", "allOf": [ { "$ref": "#/definitions/Number" } ] }, { "description": "Represents a [`String`].", "type": "string" }, { "description": "Represents a list of other [`Value`]s.", "type": "array", "items": { "$ref": "#/definitions/Value" } }, { "description": "Represents a map of [`String`] keys to [`Value`]s.", "type": "object", "additionalProperties": { "$ref": "#/definitions/Value" } } ] }, "Number": { "description": "A valid ACL number.", "anyOf": [ { "description": "Represents an [`i64`].", "type": "integer", "format": "int64" }, { "description": "Represents a [`f64`].", "type": "number", "format": "double" } ] }, "Target": { "description": "Platform target.", "oneOf": [ { "description": "MacOS.", "type": "string", "enum": [ "macOS" ] }, { "description": "Windows.", "type": "string", "enum": [ "windows" ] }, { "description": "Linux.", "type": "string", "enum": [ "linux" ] }, { "description": "Android.", "type": "string", "enum": [ "android" ] }, { "description": "iOS.", "type": "string", "enum": [ "iOS" ] } ] }, "Application": { "description": "Opener scope application.", "anyOf": [ { "description": "Open in default application.", "type": "null" }, { "description": "If true, allow open with any application.", "type": "boolean" }, { "description": "Allow specific application to open with.", "type": "string" } ] }, "ShellScopeEntryAllowedArg": { "description": "A command argument allowed to be executed by the webview API.", "anyOf": [ { "description": "A non-configurable argument that is passed to the command in the order it was specified.", "type": "string" }, { "description": "A variable that is set while calling the command from the webview API.", "type": "object", "required": [ "validator" ], "properties": { "raw": { "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", "default": false, "type": "boolean" }, "validator": { "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", "type": "string" } }, "additionalProperties": false } ] }, "ShellScopeEntryAllowedArgs": { "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", "anyOf": [ { "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", "type": "boolean" }, { "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", "type": "array", "items": { "$ref": "#/definitions/ShellScopeEntryAllowedArg" } } ] } } } ================================================ FILE: crates/tauri-app/icons/android/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: crates/tauri-app/icons/android/values/ic_launcher_background.xml ================================================ #fff ================================================ FILE: crates/tauri-app/msi-template.wxs ================================================ NOT Installed Installed AND PATCH ================================================ FILE: crates/tauri-app/splash/index.html ================================================ Vibe Kanban
================================================ FILE: crates/tauri-app/src/main.rs ================================================ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::{sync::Arc, time::Duration}; use async_trait::async_trait; use services::services::{ config::load_config_from_file, notification::{NotificationService, PushNotifier, set_global_push_notifier}, }; #[cfg(target_os = "macos")] use tauri::Manager; use tauri::{Emitter, Listener}; use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::UpdaterExt; use tokio::{sync::Mutex, time::sleep}; use tokio_util::sync::CancellationToken; use tracing_subscriber::{EnvFilter, prelude::*}; use utils::{ assets::config_path, sentry::{self as sentry_utils, SentrySource, sentry_layer}, }; use uuid::Uuid; const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); /// Native push notifier using Tauri's notification plugin. /// Emits a `navigate-to-workspace` event so the frontend can navigate to the /// relevant workspace when the user clicks the notification and the app activates. struct TauriNotifier { app_handle: tauri::AppHandle, } #[tauri::command] async fn show_system_notification(title: String, body: String) -> Result<(), String> { let config = load_config_from_file(&config_path()).await; let notification_service = NotificationService::new(Arc::new(tokio::sync::RwLock::new(config))); notification_service.notify(&title, &body, None).await; Ok(()) } #[tauri::command] fn read_clipboard_text() -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; clipboard.get_text().map_err(|e| e.to_string()) } #[async_trait] impl PushNotifier for TauriNotifier { async fn send(&self, title: &str, message: &str, workspace_id: Option) { if let Err(e) = self .app_handle .notification() .builder() .title(title) .body(message) .show() { tracing::warn!("Failed to send Tauri notification: {}", e); } if let Some(id) = workspace_id { let _ = self.app_handle.emit( "navigate-to-workspace", serde_json::json!({ "workspaceId": id.to_string() }), ); } } } fn main() { // Install rustls crypto provider before any TLS operations rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); let filter_string = format!( "warn,server={level},services={level},db={level},executors={level},deployment={level},local_deployment={level},utils={level},vibe_kanban_tauri={level}", level = log_level ); let env_filter = EnvFilter::try_new(filter_string).expect("Failed to create tracing filter"); sentry_utils::init_once(SentrySource::Desktop); tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer().with_filter(env_filter)) .with(sentry_layer()) .init(); // Shared token so we can tell the server to shut down when the app quits. let shutdown_token = Arc::new(CancellationToken::new()); let shutdown_token_for_event = shutdown_token.clone(); // Holds downloaded update bytes until the app exits or user restarts. // Created here (outside setup) so the RunEvent::Exit handler can access it. let pending_update: Arc>>> = Arc::new(Mutex::new(None)); let pending_for_setup = pending_update.clone(); let pending_for_exit = pending_update.clone(); let mut builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ show_system_notification, read_clipboard_text ]); // Only register the updater plugin in release builds — dev builds have a // placeholder endpoint that fails config deserialization. if !cfg!(debug_assertions) { builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); } builder .setup(move |app| { if cfg!(debug_assertions) { // Dev mode: frontend dev server (Vite) and backend are started // externally. Use WebviewUrl::External so that macOS WKWebView // renders with the same content scaling as the production build. tracing::info!("Running in dev mode — using external frontend/backend servers"); let window = create_window( app, tauri::WebviewUrl::External("http://localhost:3000".parse().unwrap()), )?; #[cfg(target_os = "macos")] disable_pinch_zoom(&window); let _ = window; } else { // Production: start the Axum server first, then open the window // once it's ready so the user never sees a blank/error page. let app_handle = app.handle().clone(); // Register native Tauri notifications before the server starts. set_global_push_notifier(Arc::new(TauriNotifier { app_handle: app_handle.clone(), })); let token = shutdown_token.clone(); tauri::async_runtime::spawn(async move { match server::startup::start().await { Ok(server_handle) => { let url = server_handle.url(); // Create the window on the main thread — macOS // silently drops windows created from async tasks. let url_clone = url.clone(); let create_handle = app_handle.clone(); let _ = app_handle.run_on_main_thread(move || { let webview_url = tauri::WebviewUrl::External(url_clone.parse().unwrap()); match create_window(&create_handle, webview_url) { Ok(window) => { #[cfg(target_os = "macos")] disable_pinch_zoom(&window); let _ = window; } Err(e) => tracing::error!("Failed to create window: {e}"), } }); tracing::info!("Window opened at {url}"); // Wait for either the server to exit on its own or // the external shutdown token to be cancelled. let server_token = server_handle.shutdown_token(); tauri::async_runtime::spawn(async move { token.cancelled().await; server_token.cancel(); }); if let Err(e) = server_handle.serve().await { tracing::error!("Server error: {e}"); } } Err(e) => { tracing::error!("Server failed to start: {e}"); } } }); // Check for updates in the background on startup and then // periodically. We only *download* the update here — // installing it (which replaces the app bundle on disk) is // deferred until the user exits or triggers a restart. // Installing while the app is running causes a code-signature // mismatch on macOS, which makes NSOpenPanel (and other XPC // services) return NULL and crash the app. // See tauri-apps/tauri#13047. let update_handle = app.handle().clone(); let pending_for_download = pending_for_setup.clone(); tauri::async_runtime::spawn(async move { run_periodic_update_checks(update_handle, pending_for_download).await; }); // Listen for restart request from frontend (after update downloaded). // Install the previously downloaded bytes *now*, then restart. let restart_handle = app.handle().clone(); let pending_for_install = pending_for_setup.clone(); app.listen("restart-app", move |_| { let handle = restart_handle.clone(); let pending = pending_for_install.clone(); tauri::async_runtime::spawn(async move { install_pending_update(&handle, &pending).await; handle.restart(); }); }); } Ok(()) }) .on_window_event(move |window, event| { match event { tauri::WindowEvent::CloseRequested { api, .. } => { // Hide the window instead of closing it so the app keeps // running in the background (agents/processes stay alive). // The dock icon stays visible so users can click it to reopen. api.prevent_close(); let _ = window.hide(); } tauri::WindowEvent::Destroyed => { // Only fires on actual app exit (e.g. Cmd+Q). shutdown_token_for_event.cancel(); } _ => {} } }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(move |_app, _event| { // macOS: clicking the dock icon when the window is hidden should reopen it. #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { .. } = _event { show_window(_app); } // Install any pending update when the app exits (e.g. Cmd+Q) // so the next launch uses the new version. if let tauri::RunEvent::Exit = _event { // block_on is safe here — we're on the main (AppKit) thread, // not inside the tokio runtime. tauri::async_runtime::block_on(install_pending_update(_app, &pending_for_exit)); } }); } /// Disable trackpad/touchpad pinch-to-zoom on macOS while keeping Cmd+/- zoom. /// WKWebView handles magnification at the native level — JS `preventDefault()` /// cannot block it. #[cfg(target_os = "macos")] fn disable_pinch_zoom(window: &tauri::WebviewWindow) { let _ = window.with_webview(|webview| unsafe { let wk: &objc2_web_kit::WKWebView = &*webview.inner().cast(); wk.setAllowsMagnification(false); }); } #[cfg(target_os = "macos")] fn show_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } fn create_window>( manager: &M, url: tauri::WebviewUrl, ) -> Result, tauri::Error> { let handle = manager.app_handle().clone(); let mut builder = tauri::WebviewWindowBuilder::new(manager, "main", url) .title("Vibe Kanban") .inner_size(1280.0, 800.0) .min_inner_size(800.0, 600.0) .resizable(true) .zoom_hotkeys_enabled(false) .disable_drag_drop_handler(); // macOS: overlay title bar keeps traffic lights but removes title bar chrome, // letting web content extend to the top of the window. // Traffic lights are vertically centered within the navbar height (~28px). #[cfg(target_os = "macos")] { builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true) .traffic_light_position(tauri::LogicalPosition::new(8.0, 14.0)); } // Windows/Linux: remove native decorations entirely. #[cfg(not(target_os = "macos"))] { builder = builder.decorations(false); } builder .on_new_window(move |url, _features| { tracing::info!("New window requested for URL: {}", url); let url_str = url.to_string(); let _ = handle.opener().open_url(&url_str, None::<&str>); tauri::webview::NewWindowResponse::Deny }) .build() } /// Takes the pending update bytes (if any) and installs them. /// Requires a network call to re-fetch the `Update` metadata. async fn install_pending_update(app: &tauri::AppHandle, pending: &Mutex>>) { let bytes = match pending.lock().await.take() { Some(b) => b, None => return, }; tracing::info!("Installing pending update…"); let updater = match app.updater() { Ok(u) => u, Err(e) => { tracing::error!("Failed to init updater for install: {e}"); return; } }; match updater.check().await { Ok(Some(update)) => { if let Err(e) = update.install(bytes) { tracing::error!("Failed to install update: {e}"); } else { tracing::info!("Update installed, will apply on next launch"); } } Ok(None) => { tracing::warn!("Update no longer available when trying to install"); } Err(e) => { tracing::error!("Failed to check for update during install: {e}"); } } } async fn check_for_updates(app: tauri::AppHandle, pending_update: Arc>>>) { let has_pending_update = pending_update.lock().await.is_some(); if has_pending_update { tracing::info!("Update already downloaded; skipping update check"); return; } let updater = match app.updater() { Ok(updater) => updater, Err(e) => { tracing::warn!("Failed to initialize updater: {}", e); return; } }; match updater.check().await { Ok(Some(update)) => { tracing::info!( "Update available: {} -> {}", update.current_version, update.version ); let _ = app.emit( "update-available", serde_json::json!({ "currentVersion": update.current_version.to_string(), "newVersion": update.version.to_string(), "body": update.body }), ); // Only *download* the update — do NOT install yet. // Installing replaces the app bundle on disk which // invalidates the code signature of the running process, // causing macOS XPC services (NSOpenPanel etc.) to fail. let new_version = update.version.to_string(); match update.download(|_, _| {}, || {}).await { Ok(bytes) => { tracing::info!("Update {new_version} downloaded, waiting for user to restart"); *pending_update.lock().await = Some(bytes); let _ = app.emit( "update-installed", serde_json::json!({ "newVersion": new_version }), ); } Err(e) => { tracing::error!("Failed to download update: {}", e); } } } Ok(None) => { tracing::info!("No updates available"); } Err(e) => { tracing::warn!("Failed to check for updates: {}", e); } } } async fn run_periodic_update_checks( app: tauri::AppHandle, pending_update: Arc>>>, ) { check_for_updates(app.clone(), pending_update.clone()).await; loop { sleep(UPDATE_CHECK_INTERVAL).await; check_for_updates(app.clone(), pending_update.clone()).await; } } ================================================ FILE: crates/tauri-app/tauri.conf.json ================================================ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "Vibe Kanban", "version": "0.1.33", "identifier": "ai.bloop.vibe-kanban", "build": { "beforeDevCommand": { "script": "concurrently \"pnpm run backend:dev:watch\" \"pnpm run local-web:dev\"", "cwd": "../.." }, "devUrl": "http://localhost:3000", "frontendDist": "./splash" }, "app": { "windows": [], "security": { "csp": "default-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http://localhost:* http://127.0.0.1:*; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*" } }, "bundle": { "active": true, "targets": "all", "windows": {}, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" ], "category": "DeveloperTool", "shortDescription": "AI-powered development workspace", "longDescription": "Vibe Kanban: Collaborative AI workspace for software development", "createUpdaterArtifacts": true }, "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMyN0ZCNDVCMDhFMDdEQjAKUldTd2ZlQUlXN1IvTWt1Z0ZmaWpsUUVGOHl3QUExRVVWL0crWkROSjViRUZSTXdUODN4WkFlQ1AK", "endpoints": [ "__TAURI_UPDATE_ENDPOINT__" ] } } } ================================================ FILE: crates/trusted-key-auth/Cargo.toml ================================================ [package] name = "trusted-key-auth" version = "0.1.33" edition = "2024" [dependencies] base64 = "0.22" ed25519-dalek = "2.2.0" hkdf = "0.12" hmac = "0.12" http = "1" rand = { version = "0.8", features = ["std"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" spake2 = { version = "0.5.0-pre.0", features = ["getrandom"] } thiserror = { workspace = true } tokio = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } ================================================ FILE: crates/trusted-key-auth/src/error.rs ================================================ use thiserror::Error; #[derive(Debug, Error)] pub enum TrustedKeyAuthError { #[error("Unauthorized")] Unauthorized, #[error("Bad request: {0}")] BadRequest(String), #[error("Forbidden: {0}")] Forbidden(String), #[error("Too many requests: {0}")] TooManyRequests(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), } ================================================ FILE: crates/trusted-key-auth/src/key_confirmation.rs ================================================ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use hkdf::Hkdf; use hmac::{Hmac, Mac}; use sha2::Sha256; use uuid::Uuid; use crate::error::TrustedKeyAuthError; const KEY_CONFIRMATION_INFO: &[u8] = b"key-confirmation"; const CLIENT_PROOF_CONTEXT: &[u8] = b"vk-spake2-client-proof-v2"; const SERVER_PROOF_CONTEXT: &[u8] = b"vk-spake2-server-proof-v2"; type HmacSha256 = Hmac; fn derive_confirmation_key(shared_key: &[u8]) -> [u8; 32] { let hk = Hkdf::::new(None, shared_key); let mut output = [0u8; 32]; hk.expand(KEY_CONFIRMATION_INFO, &mut output) .expect("32 bytes is valid for HKDF-SHA256"); output } /// Verify the client's proof binding the browser's public key. /// Client proof = HMAC(confirmation_key, CLIENT_CONTEXT || enrollment_id || browser_pk) pub fn verify_client_proof( shared_key: &[u8], enrollment_id: &Uuid, browser_pk_bytes: &[u8], provided_proof_b64: &str, ) -> Result<(), TrustedKeyAuthError> { let provided_proof = BASE64_STANDARD .decode(provided_proof_b64) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; let confirmation_key = derive_confirmation_key(shared_key); let mut mac = HmacSha256::new_from_slice(&confirmation_key) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; mac.update(CLIENT_PROOF_CONTEXT); mac.update(enrollment_id.as_bytes()); mac.update(browser_pk_bytes); mac.verify_slice(&provided_proof) .map_err(|_| TrustedKeyAuthError::Unauthorized) } /// Build the server's proof binding both keys. /// Server proof = HMAC(confirmation_key, SERVER_CONTEXT || enrollment_id || browser_pk || server_pk) pub fn build_server_proof( shared_key: &[u8], enrollment_id: &Uuid, browser_pk_bytes: &[u8], server_pk_bytes: &[u8], ) -> Result { let confirmation_key = derive_confirmation_key(shared_key); let mut mac = HmacSha256::new_from_slice(&confirmation_key) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; mac.update(SERVER_PROOF_CONTEXT); mac.update(enrollment_id.as_bytes()); mac.update(browser_pk_bytes); mac.update(server_pk_bytes); Ok(BASE64_STANDARD.encode(mac.finalize().into_bytes())) } #[cfg(test)] fn build_client_proof_base64( shared_key: &[u8], enrollment_id: &Uuid, browser_pk_bytes: &[u8], ) -> Result { let confirmation_key = derive_confirmation_key(shared_key); let mut mac = HmacSha256::new_from_slice(&confirmation_key) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; mac.update(CLIENT_PROOF_CONTEXT); mac.update(enrollment_id.as_bytes()); mac.update(browser_pk_bytes); Ok(BASE64_STANDARD.encode(mac.finalize().into_bytes())) } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_client_proof() { let shared_key = [9u8; 32]; let enrollment_id = Uuid::new_v4(); let browser_pk = [1u8; 32]; let proof_b64 = build_client_proof_base64(&shared_key, &enrollment_id, &browser_pk).unwrap(); verify_client_proof(&shared_key, &enrollment_id, &browser_pk, &proof_b64).unwrap(); } #[test] fn reject_invalid_client_proof() { let shared_key = [9u8; 32]; let enrollment_id = Uuid::new_v4(); let browser_pk = [1u8; 32]; let bad_proof_b64 = BASE64_STANDARD.encode([0u8; 32]); assert!( verify_client_proof(&shared_key, &enrollment_id, &browser_pk, &bad_proof_b64).is_err() ); } #[test] fn server_proof_binds_both_keys() { let shared_key = [11u8; 32]; let enrollment_id = Uuid::new_v4(); let browser_pk = [3u8; 32]; let server_pk = [4u8; 32]; let proof_b64 = build_server_proof(&shared_key, &enrollment_id, &browser_pk, &server_pk).unwrap(); // Re-compute expected proof let confirmation_key = derive_confirmation_key(&shared_key); let mut mac = HmacSha256::new_from_slice(&confirmation_key).unwrap(); mac.update(SERVER_PROOF_CONTEXT); mac.update(enrollment_id.as_bytes()); mac.update(&browser_pk); mac.update(&server_pk); let expected = BASE64_STANDARD.encode(mac.finalize().into_bytes()); assert_eq!(proof_b64, expected); } #[test] fn different_keys_produce_different_proofs() { let enrollment_id = Uuid::new_v4(); let browser_pk = [1u8; 32]; let server_pk = [2u8; 32]; let proof_a = build_server_proof(&[5u8; 32], &enrollment_id, &browser_pk, &server_pk).unwrap(); let proof_b = build_server_proof(&[6u8; 32], &enrollment_id, &browser_pk, &server_pk).unwrap(); assert_ne!(proof_a, proof_b); } } ================================================ FILE: crates/trusted-key-auth/src/lib.rs ================================================ pub mod error; pub mod key_confirmation; pub mod refresh; pub mod request_signature; pub mod runtime; pub mod spake2; pub mod trusted_keys; ================================================ FILE: crates/trusted-key-auth/src/refresh.rs ================================================ use std::time::{SystemTime, UNIX_EPOCH}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use uuid::Uuid; use crate::error::TrustedKeyAuthError; pub const REFRESH_MAX_TIMESTAMP_DRIFT_SECS: i64 = 30; pub fn build_refresh_message(timestamp: i64, nonce: &str, client_id: Uuid) -> String { format!("v1|refresh|{timestamp}|{nonce}|{client_id}") } pub fn validate_refresh_timestamp(timestamp: i64) -> Result<(), TrustedKeyAuthError> { let now = current_unix_timestamp()?; let drift = now.saturating_sub(timestamp).abs(); if drift > REFRESH_MAX_TIMESTAMP_DRIFT_SECS { return Err(TrustedKeyAuthError::Unauthorized); } Ok(()) } pub fn verify_refresh_signature( public_key: &VerifyingKey, message: &str, signature_b64: &str, ) -> Result<(), TrustedKeyAuthError> { let signature_bytes = BASE64_STANDARD .decode(signature_b64) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; let signature = Signature::from_slice(&signature_bytes).map_err(|_| TrustedKeyAuthError::Unauthorized)?; public_key .verify(message.as_bytes(), &signature) .map_err(|_| TrustedKeyAuthError::Unauthorized) } fn current_unix_timestamp() -> Result { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; i64::try_from(duration.as_secs()).map_err(|_| TrustedKeyAuthError::Unauthorized) } #[cfg(test)] mod tests { use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::{Signer, SigningKey}; use super::*; fn signing_key(seed: u8) -> SigningKey { SigningKey::from_bytes(&[seed; 32]) } #[test] fn build_refresh_message_is_stable() { let message = build_refresh_message( 1_700_000_000, "nonce-123", Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), ); assert_eq!( message, "v1|refresh|1700000000|nonce-123|11111111-1111-1111-1111-111111111111" ); } #[test] fn verify_refresh_signature_accepts_valid_signature() { let signing_key = signing_key(9); let client_id = Uuid::new_v4(); let message = build_refresh_message(1_700_000_000, "nonce", client_id); let signature_b64 = BASE64_STANDARD.encode(signing_key.sign(message.as_bytes()).to_bytes()); verify_refresh_signature(&signing_key.verifying_key(), &message, &signature_b64).unwrap(); } #[test] fn verify_refresh_signature_rejects_invalid_signature() { let trusted_key = signing_key(11); let attacker_key = signing_key(13); let client_id = Uuid::new_v4(); let message = build_refresh_message(1_700_000_000, "nonce", client_id); let signature_b64 = BASE64_STANDARD.encode(attacker_key.sign(message.as_bytes()).to_bytes()); assert!( verify_refresh_signature(&trusted_key.verifying_key(), &message, &signature_b64) .is_err() ); } #[test] fn validate_refresh_timestamp_rejects_stale_values() { let now = current_unix_timestamp().unwrap(); let stale = now - REFRESH_MAX_TIMESTAMP_DRIFT_SECS - 1; assert!(validate_refresh_timestamp(stale).is_err()); } } ================================================ FILE: crates/trusted-key-auth/src/request_signature.rs ================================================ use std::{ path::Path, time::{SystemTime, UNIX_EPOCH}, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::{Signature, VerifyingKey}; use http::{HeaderMap, Method}; use thiserror::Error; use crate::trusted_keys::load_trusted_public_keys; pub const SIGNATURE_HEADER: &str = "x-vk-signature"; pub const TIMESTAMP_HEADER: &str = "x-vk-timestamp"; pub const MAX_TIMESTAMP_DRIFT_SECONDS: i64 = 30; #[derive(Debug, Clone, Copy)] pub struct VerifiedRequestSignature { pub timestamp: i64, pub now: i64, pub drift_seconds: i64, pub trusted_key_count: usize, } #[derive(Debug, Error)] pub enum SignatureVerificationError { #[error("missing or invalid x-vk-timestamp header")] InvalidTimestampHeader, #[error("failed to read system clock")] ClockUnavailable, #[error("timestamp is outside allowed drift")] TimestampOutOfDrift { timestamp: i64, now: i64, drift_seconds: i64, max_drift_seconds: i64, }, #[error("missing or invalid x-vk-signature header")] InvalidSignatureHeader, #[error("failed to load trusted Ed25519 public keys")] TrustedKeysUnavailable, #[error("signature does not match any trusted key")] SignatureMismatch { trusted_key_count: usize }, } pub async fn verify_trusted_ed25519_signature( headers: &HeaderMap, method: &Method, path: &str, trusted_keys_path: &Path, ) -> Result { let timestamp = parse_timestamp(headers)?; let now = current_unix_timestamp().map_err(|_| SignatureVerificationError::ClockUnavailable)?; let drift_seconds = now.saturating_sub(timestamp).abs(); if !timestamp_is_within_drift(timestamp, now) { return Err(SignatureVerificationError::TimestampOutOfDrift { timestamp, now, drift_seconds, max_drift_seconds: MAX_TIMESTAMP_DRIFT_SECONDS, }); } let signature = parse_signature(headers)?; let message = build_signed_message(timestamp, method, path); let trusted_keys = load_trusted_public_keys(trusted_keys_path) .await .map_err(|_| SignatureVerificationError::TrustedKeysUnavailable)?; let trusted_key_count = trusted_keys.len(); if !verify_signature(&message, &signature, &trusted_keys) { return Err(SignatureVerificationError::SignatureMismatch { trusted_key_count }); } Ok(VerifiedRequestSignature { timestamp, now, drift_seconds, trusted_key_count, }) } fn build_signed_message(timestamp: i64, method: &Method, path: &str) -> String { format!("{timestamp}.{}.{}", method.as_str(), path) } fn parse_timestamp(headers: &HeaderMap) -> Result { let raw_timestamp = required_header(headers, TIMESTAMP_HEADER) .ok_or(SignatureVerificationError::InvalidTimestampHeader)?; raw_timestamp .parse::() .map_err(|_| SignatureVerificationError::InvalidTimestampHeader) } fn parse_signature(headers: &HeaderMap) -> Result { let raw_signature = required_header(headers, SIGNATURE_HEADER) .ok_or(SignatureVerificationError::InvalidSignatureHeader)?; parse_signature_base64(raw_signature) .map_err(|_| SignatureVerificationError::InvalidSignatureHeader) } fn parse_signature_base64(raw_signature: &str) -> Result { let signature_bytes = BASE64_STANDARD .decode(raw_signature) .map_err(|_| SignatureVerificationError::InvalidSignatureHeader)?; let signature_bytes: [u8; 64] = signature_bytes .try_into() .map_err(|_| SignatureVerificationError::InvalidSignatureHeader)?; Ok(Signature::from_bytes(&signature_bytes)) } fn required_header<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { let value = headers.get(name)?; let value = value.to_str().ok()?; let value = value.trim(); if value.is_empty() { return None; } Some(value) } fn timestamp_is_within_drift(timestamp: i64, now: i64) -> bool { let drift = now.saturating_sub(timestamp).abs(); drift <= MAX_TIMESTAMP_DRIFT_SECONDS } fn current_unix_timestamp() -> Result { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| SignatureVerificationError::ClockUnavailable)?; i64::try_from(duration.as_secs()).map_err(|_| SignatureVerificationError::ClockUnavailable) } fn verify_signature(message: &str, signature: &Signature, trusted_keys: &[VerifyingKey]) -> bool { trusted_keys .iter() .any(|key| key.verify_strict(message.as_bytes(), signature).is_ok()) } #[cfg(test)] mod tests { use std::path::PathBuf; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::{Signer, SigningKey}; use http::{HeaderMap, HeaderValue, Method}; use tokio::fs; use uuid::Uuid; use super::*; fn signing_key(seed: u8) -> SigningKey { SigningKey::from_bytes(&[seed; 32]) } #[test] fn accepts_signature_from_trusted_key() { let trusted_signing_key = signing_key(7); let trusted_public_key = trusted_signing_key.verifying_key(); let timestamp = 1_700_000_000_i64; let message = build_signed_message(timestamp, &Method::POST, "/auth/signed-test"); let signature = trusted_signing_key.sign(message.as_bytes()); assert!(verify_signature( &message, &signature, &[trusted_public_key] )); } #[test] fn rejects_signature_from_untrusted_key() { let trusted_signing_key = signing_key(11); let untrusted_signing_key = signing_key(13); let timestamp = 1_700_000_000_i64; let message = build_signed_message(timestamp, &Method::POST, "/auth/signed-test"); let signature = untrusted_signing_key.sign(message.as_bytes()); assert!(!verify_signature( &message, &signature, &[trusted_signing_key.verifying_key()] )); } #[test] fn rejects_stale_timestamps() { let now = 1_700_000_000_i64; assert!(timestamp_is_within_drift(now, now)); assert!(timestamp_is_within_drift( now - MAX_TIMESTAMP_DRIFT_SECONDS, now )); assert!(!timestamp_is_within_drift( now - MAX_TIMESTAMP_DRIFT_SECONDS - 1, now )); } #[test] fn rejects_malformed_signature() { assert!(parse_signature_base64("not-base64").is_err()); let short_signature = BASE64_STANDARD.encode([1_u8; 63]); assert!(parse_signature_base64(&short_signature).is_err()); } #[tokio::test] async fn verifies_request_signature_end_to_end() { let signing_key = signing_key(17); let public_key_b64 = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); let trusted_keys_json = serde_json::json!({ "clients": [ { "client_id": Uuid::new_v4(), "client_name": "Test Client", "client_browser": "Chrome", "client_os": "macOS", "client_device": "desktop", "public_key_b64": public_key_b64 } ] }) .to_string(); let trusted_keys_path = temp_trusted_keys_path(); fs::write(&trusted_keys_path, trusted_keys_json) .await .unwrap(); let path = "/api/auth/signed-test"; let timestamp = current_unix_timestamp().unwrap(); let message = build_signed_message(timestamp, &Method::POST, path); let signature_b64 = BASE64_STANDARD.encode(signing_key.sign(message.as_bytes()).to_bytes()); let mut headers = HeaderMap::new(); headers.insert( TIMESTAMP_HEADER, HeaderValue::from_str(×tamp.to_string()).unwrap(), ); headers.insert( SIGNATURE_HEADER, HeaderValue::from_str(&signature_b64).unwrap(), ); let result = verify_trusted_ed25519_signature(&headers, &Method::POST, path, &trusted_keys_path) .await; assert!(result.is_ok()); let _ = fs::remove_file(&trusted_keys_path).await; } fn temp_trusted_keys_path() -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("vk-trusted-keys-{}.json", Uuid::new_v4())); path } } ================================================ FILE: crates/trusted-key-auth/src/runtime.rs ================================================ use std::{ collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}, }; use tokio::sync::RwLock; use uuid::Uuid; use crate::{ error::TrustedKeyAuthError, trusted_keys::{ TrustedRelayClient, list_trusted_clients, remove_trusted_client, upsert_trusted_client, }, }; #[derive(Clone)] pub struct TrustedKeyAuthRuntime { trusted_keys_path: PathBuf, pake_enrollments: Arc>>, enrollment_code: Arc>>, rate_limit_windows: Arc>>>, refresh_nonces: Arc>>, } #[derive(Debug, Clone)] struct PendingPakeEnrollment { shared_key: Vec, created_at: Instant, } const PAKE_ENROLLMENT_TTL: Duration = Duration::from_secs(5 * 60); const REFRESH_NONCE_TTL: Duration = Duration::from_secs(2 * 60); impl TrustedKeyAuthRuntime { pub fn new(trusted_keys_path: PathBuf) -> Self { Self { trusted_keys_path, pake_enrollments: Default::default(), enrollment_code: Default::default(), rate_limit_windows: Default::default(), refresh_nonces: Default::default(), } } pub async fn persist_trusted_client( &self, client: TrustedRelayClient, ) -> Result { upsert_trusted_client(&self.trusted_keys_path, client).await } pub async fn list_trusted_clients( &self, ) -> Result, TrustedKeyAuthError> { list_trusted_clients(&self.trusted_keys_path).await } pub async fn remove_trusted_client( &self, client_id: Uuid, ) -> Result { remove_trusted_client(&self.trusted_keys_path, client_id).await } pub async fn find_trusted_client( &self, client_id: Uuid, ) -> Result, TrustedKeyAuthError> { let clients = list_trusted_clients(&self.trusted_keys_path).await?; Ok(clients .into_iter() .find(|client| client.client_id == client_id)) } pub async fn store_pake_enrollment(&self, enrollment_id: Uuid, shared_key: Vec) { self.pake_enrollments.write().await.insert( enrollment_id, PendingPakeEnrollment { shared_key, created_at: Instant::now(), }, ); } pub async fn take_pake_enrollment(&self, enrollment_id: &Uuid) -> Option> { let mut enrollments = self.pake_enrollments.write().await; let enrollment = enrollments.remove(enrollment_id)?; if enrollment.created_at.elapsed() > PAKE_ENROLLMENT_TTL { return None; } Some(enrollment.shared_key) } pub async fn get_or_set_enrollment_code(&self, new_code: String) -> String { let mut enrollment_code = self.enrollment_code.write().await; if let Some(existing_code) = enrollment_code.as_ref() { return existing_code.clone(); } *enrollment_code = Some(new_code.clone()); new_code } pub async fn consume_enrollment_code(&self, enrollment_code: &str) -> bool { let mut stored_code = self.enrollment_code.write().await; if stored_code.as_deref() != Some(enrollment_code) { return false; } *stored_code = None; true } pub async fn enforce_rate_limit( &self, bucket: &str, max_requests: usize, window: Duration, ) -> Result<(), TrustedKeyAuthError> { let now = Instant::now(); let mut windows = self.rate_limit_windows.write().await; let entry = windows.entry(bucket.to_string()).or_default(); entry.retain(|timestamp| now.duration_since(*timestamp) <= window); if entry.len() >= max_requests { return Err(TrustedKeyAuthError::TooManyRequests( "Too many requests. Please wait and try again.".to_string(), )); } entry.push(now); Ok(()) } pub async fn claim_refresh_nonce(&self, nonce: &str) -> Result<(), TrustedKeyAuthError> { let normalized = nonce.trim(); if normalized.is_empty() || normalized.len() > 128 { return Err(TrustedKeyAuthError::Unauthorized); } let now = Instant::now(); let mut seen = self.refresh_nonces.write().await; seen.retain(|_, inserted_at| now.duration_since(*inserted_at) <= REFRESH_NONCE_TTL); if seen.contains_key(normalized) { return Err(TrustedKeyAuthError::Unauthorized); } seen.insert(normalized.to_string(), now); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn claim_refresh_nonce_rejects_replay() { let runtime = TrustedKeyAuthRuntime::new(PathBuf::from("/tmp/unused-trusted-keys.json")); runtime.claim_refresh_nonce("nonce-1").await.unwrap(); assert!(runtime.claim_refresh_nonce("nonce-1").await.is_err()); } #[tokio::test] async fn claim_refresh_nonce_rejects_blank_values() { let runtime = TrustedKeyAuthRuntime::new(PathBuf::from("/tmp/unused-trusted-keys.json")); assert!(runtime.claim_refresh_nonce(" ").await.is_err()); } } ================================================ FILE: crates/trusted-key-auth/src/spake2.rs ================================================ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use rand::Rng; use spake2::{Ed25519Group, Identity, Password, Spake2, SysRng, UnwrapErr}; use crate::error::TrustedKeyAuthError; const SPAKE2_CLIENT_ID: &[u8] = b"vibe-kanban-browser"; const SPAKE2_SERVER_ID: &[u8] = b"vibe-kanban-server"; pub const ENROLLMENT_CODE_LENGTH: usize = 6; const ENROLLMENT_CODE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; #[derive(Debug)] pub struct Spake2StartOutcome { pub enrollment_code: String, pub shared_key: Vec, pub server_message_b64: String, } pub fn start_spake2_enrollment( raw_enrollment_code: &str, client_message_b64: &str, ) -> Result { let enrollment_code = normalize_enrollment_code(raw_enrollment_code)?; let client_message = decode_base64(client_message_b64) .map_err(|_| TrustedKeyAuthError::BadRequest("Invalid client_message_b64".to_string()))?; let password = Password::new(enrollment_code.as_bytes()); let id_a = Identity::new(SPAKE2_CLIENT_ID); let id_b = Identity::new(SPAKE2_SERVER_ID); let (server_state, server_message) = Spake2::::start_b_with_rng(&password, &id_a, &id_b, UnwrapErr(SysRng)); let shared_key = server_state .finish(&client_message) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; Ok(Spake2StartOutcome { enrollment_code, shared_key, server_message_b64: BASE64_STANDARD.encode(server_message), }) } pub fn generate_one_time_code() -> String { let mut rng = rand::thread_rng(); let mut code = String::with_capacity(ENROLLMENT_CODE_LENGTH); for _ in 0..ENROLLMENT_CODE_LENGTH { let idx = rng.gen_range(0..ENROLLMENT_CODE_CHARSET.len()); code.push(ENROLLMENT_CODE_CHARSET[idx] as char); } code } pub fn normalize_enrollment_code(raw_code: &str) -> Result { let code = raw_code.trim().to_ascii_uppercase(); if code.len() != ENROLLMENT_CODE_LENGTH { return Err(TrustedKeyAuthError::BadRequest(format!( "Invalid enrollment code length. Expected {ENROLLMENT_CODE_LENGTH} characters." ))); } if !code .bytes() .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) { return Err(TrustedKeyAuthError::BadRequest( "Enrollment code must contain only A-Z and 0-9.".to_string(), )); } Ok(code) } fn decode_base64(input: &str) -> Result, TrustedKeyAuthError> { BASE64_STANDARD .decode(input) .map_err(|_| TrustedKeyAuthError::Unauthorized) } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_enrollment_code_accepts_valid_input() { let normalized = normalize_enrollment_code("ab12z9").unwrap(); assert_eq!(normalized, "AB12Z9"); } #[test] fn normalize_enrollment_code_rejects_invalid_characters() { assert!(normalize_enrollment_code("AB!2Z9").is_err()); } #[test] fn generate_one_time_code_uses_expected_charset_and_length() { let code = generate_one_time_code(); assert_eq!(code.len(), ENROLLMENT_CODE_LENGTH); assert!( code.bytes() .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) ); } } ================================================ FILE: crates/trusted-key-auth/src/trusted_keys.rs ================================================ use std::path::Path; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use ed25519_dalek::VerifyingKey; use serde::{Deserialize, Serialize}; use tokio::fs; use uuid::Uuid; use crate::error::TrustedKeyAuthError; pub const TRUSTED_KEYS_FILE_NAME: &str = "trusted_ed25519_public_keys.json"; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TrustedRelayClient { pub client_id: Uuid, pub client_name: String, pub client_browser: String, pub client_os: String, pub client_device: String, pub public_key_b64: String, } #[derive(Debug, Default, Deserialize, Serialize)] struct TrustedRelayClientsFile { clients: Vec, } pub async fn upsert_trusted_client( trusted_keys_path: &Path, client: TrustedRelayClient, ) -> Result { validate_client(&client)?; let mut trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?; if let Some(existing_client) = trusted_clients_file .clients .iter_mut() .find(|existing_client| { existing_client.client_id == client.client_id || existing_client.public_key_b64 == client.public_key_b64 }) { *existing_client = client; write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?; return Ok(false); } trusted_clients_file.clients.push(client); write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?; Ok(true) } pub async fn load_trusted_public_keys( trusted_keys_path: &Path, ) -> Result, TrustedKeyAuthError> { let trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?; if trusted_clients_file.clients.is_empty() { return Err(TrustedKeyAuthError::Unauthorized); } let mut parsed_keys = Vec::with_capacity(trusted_clients_file.clients.len()); for client in &trusted_clients_file.clients { let parsed_key = parse_public_key_base64(&client.public_key_b64) .map_err(|_| TrustedKeyAuthError::Unauthorized)?; parsed_keys.push(parsed_key); } Ok(parsed_keys) } pub async fn list_trusted_clients( trusted_keys_path: &Path, ) -> Result, TrustedKeyAuthError> { Ok(read_trusted_clients_file(trusted_keys_path).await?.clients) } pub async fn remove_trusted_client( trusted_keys_path: &Path, client_id: Uuid, ) -> Result { let mut trusted_clients_file = read_trusted_clients_file(trusted_keys_path).await?; let previous_len = trusted_clients_file.clients.len(); trusted_clients_file .clients .retain(|client| client.client_id != client_id); if trusted_clients_file.clients.len() == previous_len { return Ok(false); } write_trusted_clients_file(trusted_keys_path, &trusted_clients_file).await?; Ok(true) } pub fn parse_public_key_base64(raw_public_key: &str) -> Result { let public_key_bytes = decode_base64(raw_public_key)?; let public_key_bytes: [u8; 32] = public_key_bytes .try_into() .map_err(|_| TrustedKeyAuthError::Unauthorized)?; VerifyingKey::from_bytes(&public_key_bytes).map_err(|_| TrustedKeyAuthError::Unauthorized) } async fn read_trusted_clients_file( trusted_keys_path: &Path, ) -> Result { if !trusted_keys_path.exists() { return Ok(TrustedRelayClientsFile::default()); } let file_contents = fs::read_to_string(trusted_keys_path).await?; if file_contents.trim().is_empty() { return Ok(TrustedRelayClientsFile::default()); } let trusted_clients_file: TrustedRelayClientsFile = serde_json::from_str(&file_contents) .map_err(|error| { TrustedKeyAuthError::BadRequest(format!("Trusted key file is invalid JSON: {error}")) })?; for client in &trusted_clients_file.clients { validate_client(client)?; } Ok(trusted_clients_file) } async fn write_trusted_clients_file( trusted_keys_path: &Path, trusted_clients_file: &TrustedRelayClientsFile, ) -> Result<(), TrustedKeyAuthError> { let serialized = serde_json::to_string_pretty(trusted_clients_file).map_err(|error| { TrustedKeyAuthError::BadRequest(format!("Failed to serialize trusted keys: {error}")) })?; fs::write(trusted_keys_path, format!("{serialized}\n")).await?; Ok(()) } fn validate_client(client: &TrustedRelayClient) -> Result<(), TrustedKeyAuthError> { if client.client_name.trim().is_empty() { return Err(TrustedKeyAuthError::BadRequest( "Trusted key file contains invalid client name".to_string(), )); } if client.client_browser.trim().is_empty() { return Err(TrustedKeyAuthError::BadRequest( "Trusted key file contains invalid client browser".to_string(), )); } if client.client_os.trim().is_empty() { return Err(TrustedKeyAuthError::BadRequest( "Trusted key file contains invalid client OS".to_string(), )); } if client.client_device.trim().is_empty() { return Err(TrustedKeyAuthError::BadRequest( "Trusted key file contains invalid client device".to_string(), )); } parse_public_key_base64(&client.public_key_b64).map_err(|_| { TrustedKeyAuthError::BadRequest("Trusted key file contains invalid keys".to_string()) })?; Ok(()) } fn decode_base64(input: &str) -> Result, TrustedKeyAuthError> { BASE64_STANDARD .decode(input) .map_err(|_| TrustedKeyAuthError::Unauthorized) } #[cfg(test)] mod tests { use std::path::PathBuf; use ed25519_dalek::SigningKey; use tokio::fs; use uuid::Uuid; use super::*; fn test_public_key() -> VerifyingKey { SigningKey::from_bytes(&[7; 32]).verifying_key() } #[test] fn parse_public_key_base64_accepts_valid_key() { let public_key = test_public_key(); let key_b64 = BASE64_STANDARD.encode(public_key.as_bytes()); let parsed = parse_public_key_base64(&key_b64).unwrap(); assert_eq!(parsed.as_bytes(), public_key.as_bytes()); } #[tokio::test] async fn can_upsert_list_and_remove_trusted_clients() { let trusted_keys_path = temp_trusted_keys_path(); let key_b64 = BASE64_STANDARD.encode(test_public_key().as_bytes()); let client_id = Uuid::new_v4(); let inserted = upsert_trusted_client( &trusted_keys_path, TrustedRelayClient { client_id, client_name: "Chrome on macOS (Desktop)".to_string(), client_browser: "Chrome".to_string(), client_os: "macOS".to_string(), client_device: "desktop".to_string(), public_key_b64: key_b64.clone(), }, ) .await .unwrap(); assert!(inserted); let clients = list_trusted_clients(&trusted_keys_path).await.unwrap(); assert_eq!(clients.len(), 1); assert_eq!(clients[0].client_id, client_id); assert_eq!(clients[0].public_key_b64, key_b64); let removed = remove_trusted_client(&trusted_keys_path, client_id) .await .unwrap(); assert!(removed); let clients = list_trusted_clients(&trusted_keys_path).await.unwrap(); assert!(clients.is_empty()); let _ = fs::remove_file(&trusted_keys_path).await; } fn temp_trusted_keys_path() -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("vk-trusted-keys-{}.json", Uuid::new_v4())); path } } ================================================ FILE: crates/utils/Cargo.toml ================================================ [package] name = "utils" version = "0.1.33" edition = "2024" [dependencies] tokio-util = { version = "0.7", features = ["io", "codec"] } bytes = "1.0" shlex = "1.3.0" axum = { workspace = true, features = ["ws"] } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } rust-embed = "8.2" directories = "6.0.0" open = "5.3.2" regex = "1.11.1" sentry = { version = "0.46.2", default-features = false, features = ["anyhow", "backtrace", "panic", "debug-images", "reqwest", "rustls"] } sentry-tracing = { version = "0.46.2", default-features = false, features = ["backtrace"] } json-patch = "2.0" jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } tokio = { workspace = true } futures = "0.3.31" tokio-stream = { version = "0.1.17", features = ["sync"] } shellexpand = "3.1.1" which = "8.0.0" similar = "2" git2 = { workspace = true } dirs = "5.0" thiserror = { workspace = true } command-group = { version = "5.0", features = ["with-tokio"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal", "process"] } [target.'cfg(windows)'.dependencies] winreg = "0.55" windows-sys = { version = "0.61", features = ["Win32_System_Environment"] } ================================================ FILE: crates/utils/src/approvals.rs ================================================ use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use uuid::Uuid; pub const APPROVAL_TIMEOUT_SECONDS: i64 = 36000; // 10 hours #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ApprovalRequest { pub id: String, pub tool_name: String, pub execution_process_id: Uuid, pub created_at: DateTime, pub timeout_at: DateTime, } impl ApprovalRequest { pub fn new(tool_name: String, execution_process_id: Uuid) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4().to_string(), tool_name, execution_process_id, created_at: now, timeout_at: now + Duration::seconds(APPROVAL_TIMEOUT_SECONDS), } } } /// Status of a tool permission request (approve/deny for tool execution). #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "status", rename_all = "snake_case")] pub enum ApprovalStatus { Pending, Approved, Denied { #[ts(optional)] reason: Option, }, TimedOut, } /// A question–answer pair. `answer` holds one or more selected labels/values. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct QuestionAnswer { pub question: String, pub answer: Vec, } /// Status of a question answer request. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "status", rename_all = "snake_case")] pub enum QuestionStatus { Answered { answers: Vec }, TimedOut, } // Tracks both approval and question answers requests #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "status", rename_all = "snake_case")] pub enum ApprovalOutcome { Approved, Denied { #[ts(optional)] reason: Option, }, Answered { answers: Vec, }, TimedOut, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct ApprovalResponse { pub execution_process_id: Uuid, pub status: ApprovalOutcome, } ================================================ FILE: crates/utils/src/assets.rs ================================================ use directories::ProjectDirs; use rust_embed::RustEmbed; const PROJECT_ROOT: &str = env!("CARGO_MANIFEST_DIR"); pub fn asset_dir() -> std::path::PathBuf { let path = if cfg!(debug_assertions) { std::path::PathBuf::from(PROJECT_ROOT).join("../../dev_assets") } else { prod_asset_dir_path() }; // Ensure the directory exists if !path.exists() { std::fs::create_dir_all(&path).expect("Failed to create asset directory"); } path // ✔ macOS → ~/Library/Application Support/MyApp // ✔ Linux → ~/.local/share/myapp (respects XDG_DATA_HOME) // ✔ Windows → %APPDATA%\Example\MyApp } pub fn prod_asset_dir_path() -> std::path::PathBuf { ProjectDirs::from("ai", "bloop", "vibe-kanban") .expect("OS didn't give us a home directory") .data_dir() .to_path_buf() } pub fn config_path() -> std::path::PathBuf { asset_dir().join("config.json") } pub fn profiles_path() -> std::path::PathBuf { asset_dir().join("profiles.json") } pub fn credentials_path() -> std::path::PathBuf { asset_dir().join("credentials.json") } pub fn trusted_keys_path() -> std::path::PathBuf { asset_dir().join("trusted_ed25519_public_keys.json") } pub fn server_signing_key_path() -> std::path::PathBuf { asset_dir().join("server_ed25519_signing_key") } #[derive(RustEmbed)] #[folder = "../../assets/sounds"] pub struct SoundAssets; #[derive(RustEmbed)] #[folder = "../../assets/scripts"] pub struct ScriptAssets; ================================================ FILE: crates/utils/src/browser.rs ================================================ use crate::{command_ext::NoWindowExt, is_wsl2}; /// Open URL in browser with WSL2 support pub async fn open_browser(url: &str) -> Result<(), Box> { if is_wsl2() { // In WSL2, use PowerShell to open the browser tokio::process::Command::new("powershell.exe") .arg("-Command") .arg(format!("Start-Process '{url}'")) .no_window() .spawn()?; Ok(()) } else { // Use the standard open crate for other platforms open::that(url).map_err(|e| e.into()) } } ================================================ FILE: crates/utils/src/command_ext.rs ================================================ //! Extension traits to suppress console windows on Windows. //! //! On Windows, spawned child processes open a visible console window by //! default. Call `.no_window()` before `.spawn()` or `.output()` to set //! the `CREATE_NO_WINDOW` creation flag and prevent this. //! //! On non-Windows platforms the methods are no-ops. use command_group::{AsyncCommandGroup, AsyncGroupChild}; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; /// Adds a `.no_window()` builder method that suppresses the console window /// on Windows. No-op on other platforms. pub trait NoWindowExt { fn no_window(&mut self) -> &mut Self; } impl NoWindowExt for std::process::Command { #[cfg(windows)] fn no_window(&mut self) -> &mut Self { use std::os::windows::process::CommandExt; self.creation_flags(CREATE_NO_WINDOW) } #[cfg(not(windows))] fn no_window(&mut self) -> &mut Self { self } } impl NoWindowExt for tokio::process::Command { #[cfg(windows)] fn no_window(&mut self) -> &mut Self { use std::os::windows::process::CommandExt; self.creation_flags(CREATE_NO_WINDOW) } #[cfg(not(windows))] fn no_window(&mut self) -> &mut Self { self } } /// Adds a `.group_spawn_no_window()` helper for command-group spawns that /// suppresses the console window on Windows. No-op on other platforms. pub trait GroupSpawnNoWindowExt { fn group_spawn_no_window(&mut self) -> std::io::Result; } impl GroupSpawnNoWindowExt for tokio::process::Command { fn group_spawn_no_window(&mut self) -> std::io::Result { let mut group = self.group(); #[cfg(windows)] group.creation_flags(CREATE_NO_WINDOW); group.spawn() } } ================================================ FILE: crates/utils/src/diff.rs ================================================ use std::borrow::Cow; use git2::{DiffOptions, Patch}; use serde::{Deserialize, Serialize}; use similar::TextDiff; use ts_rs::TS; use uuid::Uuid; // Structs compatible with props: https://github.com/MrWangJustToDo/git-diff-view #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct FileDiffDetails { pub file_name: Option, pub content: Option, } // Worktree diffs for the diffs tab: minimal, no hunks, optional full contents #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct Diff { pub change: DiffChangeKind, pub old_path: Option, pub new_path: Option, pub old_content: Option, pub new_content: Option, /// True when file contents are intentionally omitted (e.g., too large) pub content_omitted: bool, /// Optional precomputed stats for omitted content pub additions: Option, pub deletions: Option, pub repo_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub enum DiffChangeKind { Added, Deleted, Modified, Renamed, Copied, PermissionChange, } // ============================== // Unified diff utility functions // ============================== /// Converts a replace diff to a list of unified diff hunks. /// Uses a context limit of 3 lines. fn create_unified_diff_hunks(old: &str, new: &str) -> Vec { let old = ensure_newline(old); let new = ensure_newline(new); let diff = TextDiff::from_lines(&old, &new); // Generate unified diff with context let unified_diff = diff .unified_diff() .context_radius(3) .header("a", "b") .to_string(); extract_unified_diff_hunks(&unified_diff) } /// Creates a full unified diff with the file path in the header. pub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String { let hunks = create_unified_diff_hunks(old, new); concatenate_diff_hunks(file_path, &hunks) } /// Compute addition/deletion counts between two text snapshots. pub fn compute_line_change_counts(old: &str, new: &str) -> (usize, usize) { let old = ensure_newline(old); let new = ensure_newline(new); let mut opts = DiffOptions::new(); opts.context_lines(0); match Patch::from_buffers(old.as_bytes(), None, new.as_bytes(), None, Some(&mut opts)) .and_then(|patch| patch.line_stats()) { Ok((_, adds, dels)) => (adds, dels), Err(e) => { tracing::error!("git2 diff failed: {}", e); (0, 0) } } } // ensure a line ends with a newline character fn ensure_newline(line: &str) -> Cow<'_, str> { if line.ends_with('\n') { Cow::Borrowed(line) } else { let mut owned = line.to_owned(); owned.push('\n'); Cow::Owned(owned) } } /// Extracts unified diff hunks from a string containing a full unified diff. /// Tolerates non-diff lines and missing `@@`` hunk headers. pub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec { let lines = unified_diff.split_inclusive('\n').collect::>(); if !lines.iter().any(|l| l.starts_with("@@")) { // No @@ hunk headers: treat as a single hunk let hunk = lines .iter() .copied() .filter(|line| line.starts_with([' ', '+', '-'])) .collect::(); let old_count = lines .iter() .filter(|line| line.starts_with(['-', ' '])) .count(); let new_count = lines .iter() .filter(|line| line.starts_with(['+', ' '])) .count(); return if hunk.is_empty() { vec![] } else { vec![format!("@@ -1,{old_count} +1,{new_count} @@\n{hunk}")] }; } let mut hunks = vec![]; let mut current_hunk: Option = None; // Collect hunks starting with @@ headers for line in lines { if line.starts_with("@@") { // new hunk starts if let Some(hunk) = current_hunk.take() { // flush current hunk if !hunk.is_empty() { hunks.push(hunk); } } current_hunk = Some(line.to_string()); } else if let Some(ref mut hunk) = current_hunk { if line.starts_with([' ', '+', '-']) { // hunk content hunk.push_str(line); } else { // unknown line, flush current hunk if !hunk.is_empty() { hunks.push(hunk.clone()); } current_hunk = None; } } } // we have reached the end. flush the last hunk if it exists if let Some(hunk) = current_hunk && !hunk.is_empty() { hunks.push(hunk); } // Fix hunk headers if they are empty @@\n hunks = fix_hunk_headers(hunks); hunks } // Helper function to ensure valid hunk headers fn fix_hunk_headers(hunks: Vec) -> Vec { if hunks.is_empty() { return hunks; } let mut new_hunks = Vec::new(); // if hunk header is empty @@\n, ten we need to replace it with a valid header for hunk in hunks { let mut lines = hunk .split_inclusive('\n') .map(str::to_string) .collect::>(); if lines.len() < 2 { // empty hunk, skip continue; } let header = &lines[0]; if !header.starts_with("@@") { // no header, skip continue; } if header.trim() == "@@" { // empty header, replace with a valid one lines.remove(0); let old_count = lines .iter() .filter(|line| line.starts_with(['-', ' '])) .count(); let new_count = lines .iter() .filter(|line| line.starts_with(['+', ' '])) .count(); let new_header = format!("@@ -1,{old_count} +1,{new_count} @@"); lines.insert(0, new_header); new_hunks.push(lines.join("")); } else { // valid header, keep as is new_hunks.push(hunk); } } new_hunks } /// Creates a full unified diff with the file path in the header, pub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String { let mut unified_diff = String::new(); let header = format!("--- a/{file_path}\n+++ b/{file_path}\n"); unified_diff.push_str(&header); if !hunks.is_empty() { let lines = hunks .iter() .flat_map(|hunk| hunk.lines()) .filter(|line| line.starts_with("@@ ") || line.starts_with([' ', '+', '-'])) .collect::>(); unified_diff.push_str(lines.join("\n").as_str()); if !unified_diff.ends_with('\n') { unified_diff.push('\n'); } } unified_diff } /// Normalizes a unified diff the format supported by the diff viewer, pub fn normalize_unified_diff(file_path: &str, unified_diff: &str) -> String { let hunks = extract_unified_diff_hunks(unified_diff); concatenate_diff_hunks(file_path, &hunks) } ================================================ FILE: crates/utils/src/execution_logs.rs ================================================ use std::path::{Path, PathBuf}; use tokio::io::AsyncWriteExt; use uuid::Uuid; use crate::{assets::asset_dir, log_msg::LogMsg}; pub const EXECUTION_LOGS_DIRNAME: &str = "sessions"; pub fn process_logs_session_dir(session_id: Uuid) -> PathBuf { resolve_process_logs_session_dir(&asset_dir(), session_id) } pub fn process_log_file_path(session_id: Uuid, process_id: Uuid) -> PathBuf { process_log_file_path_in_root(&asset_dir(), session_id, process_id) } pub fn process_log_file_path_in_root(root: &Path, session_id: Uuid, process_id: Uuid) -> PathBuf { resolve_process_logs_session_dir(root, session_id) .join("processes") .join(format!("{}.jsonl", process_id)) } pub struct ExecutionLogWriter { path: PathBuf, file: tokio::fs::File, } impl ExecutionLogWriter { pub async fn new(path: PathBuf) -> std::io::Result { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } let file = tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .await?; Ok(Self { path, file }) } pub async fn new_for_execution(session_id: Uuid, execution_id: Uuid) -> std::io::Result { Self::new(process_log_file_path(session_id, execution_id)).await } pub fn path(&self) -> &Path { &self.path } pub async fn append_jsonl_line(&mut self, jsonl_line: &str) -> std::io::Result<()> { self.file.write_all(jsonl_line.as_bytes()).await } } pub async fn read_execution_log_file(path: &Path) -> std::io::Result { tokio::fs::read_to_string(path).await } pub fn parse_log_jsonl_lossy(execution_id: Uuid, jsonl: &str) -> Vec { let mut messages = Vec::new(); let mut bad_lines = 0usize; for line in jsonl.lines() { if line.trim().is_empty() { continue; } match serde_json::from_str::(line) { Ok(msg) => messages.push(msg), Err(e) => { bad_lines += 1; if bad_lines <= 3 { tracing::warn!( "Skipping unparsable log line for execution {}: {}", execution_id, e ); } } } } if bad_lines > 3 { tracing::warn!( "Skipped {} unparsable log lines for execution {}", bad_lines, execution_id ); } messages } fn uuid_prefix2(id: Uuid) -> String { let s = id.to_string(); s.chars().take(2).collect() } fn resolve_process_logs_session_dir(root: &Path, session_id: Uuid) -> PathBuf { root.join(EXECUTION_LOGS_DIRNAME) .join(uuid_prefix2(session_id)) .join(session_id.to_string()) } ================================================ FILE: crates/utils/src/jwt.rs ================================================ use chrono::{DateTime, Utc}; use jsonwebtoken::dangerous::insecure_decode; use serde::Deserialize; use thiserror::Error; use uuid::Uuid; #[derive(Debug, Error)] pub enum TokenClaimsError { #[error("failed to decode JWT: {0}")] Decode(#[from] jsonwebtoken::errors::Error), #[error("missing `exp` claim in token")] MissingExpiration, #[error("invalid `exp` value `{0}`")] InvalidExpiration(i64), #[error("missing `sub` claim in token")] MissingSubject, #[error("invalid `sub` value: {0}")] InvalidSubject(String), } #[derive(Debug, Deserialize)] struct ExpClaim { exp: Option, } #[derive(Debug, Deserialize)] struct SubClaim { sub: Option, } /// Extract the expiration timestamp from a JWT without verifying its signature. pub fn extract_expiration(token: &str) -> Result, TokenClaimsError> { let data = insecure_decode::(token)?; let exp = data.claims.exp.ok_or(TokenClaimsError::MissingExpiration)?; DateTime::from_timestamp(exp, 0).ok_or(TokenClaimsError::InvalidExpiration(exp)) } /// Extract the subject (user ID) from a JWT without verifying its signature. pub fn extract_subject(token: &str) -> Result { let data = insecure_decode::(token)?; let sub = data.claims.sub.ok_or(TokenClaimsError::MissingSubject)?; Uuid::parse_str(&sub).map_err(|_| TokenClaimsError::InvalidSubject(sub)) } ================================================ FILE: crates/utils/src/lib.rs ================================================ use std::{env, sync::OnceLock}; use directories::ProjectDirs; pub mod approvals; pub mod assets; pub mod browser; pub mod command_ext; pub mod diff; pub mod execution_logs; pub mod jwt; pub mod log_msg; pub mod msg_store; pub mod path; pub mod port_file; pub mod process; pub mod response; pub mod sentry; pub mod shell; pub mod stream_lines; pub mod text; pub mod tokio; pub mod version; /// Cache for WSL2 detection result static WSL2_CACHE: OnceLock = OnceLock::new(); /// Check if running in WSL2 (cached) pub fn is_wsl2() -> bool { *WSL2_CACHE.get_or_init(|| { // Check for WSL environment variables if std::env::var("WSL_DISTRO_NAME").is_ok() || std::env::var("WSLENV").is_ok() { tracing::debug!("WSL2 detected via environment variables"); return true; } // Check /proc/version for WSL2 signature if let Ok(version) = std::fs::read_to_string("/proc/version") && (version.contains("WSL2") || version.contains("microsoft")) { tracing::debug!("WSL2 detected via /proc/version"); return true; } tracing::debug!("WSL2 not detected"); false }) } pub fn cache_dir() -> std::path::PathBuf { let proj = if cfg!(debug_assertions) { ProjectDirs::from("ai", "bloop-dev", env!("CARGO_PKG_NAME")) .expect("OS didn't give us a home directory") } else { ProjectDirs::from("ai", "bloop", env!("CARGO_PKG_NAME")) .expect("OS didn't give us a home directory") }; // ✔ macOS → ~/Library/Caches/MyApp // ✔ Linux → ~/.cache/myapp (respects XDG_CACHE_HOME) // ✔ Windows → %LOCALAPPDATA%\Example\MyApp proj.cache_dir().to_path_buf() } // Get or create cached PowerShell script file pub async fn get_powershell_script() -> Result> { use std::io::Write; let cache_dir = cache_dir(); let script_path = cache_dir.join("toast-notification.ps1"); // Check if cached file already exists and is valid if script_path.exists() { // Verify file has content (basic validation) if let Ok(metadata) = std::fs::metadata(&script_path) && metadata.len() > 0 { return Ok(script_path); } } // File doesn't exist or is invalid, create it let script_content = assets::ScriptAssets::get("toast-notification.ps1") .ok_or("Embedded PowerShell script not found: toast-notification.ps1")? .data; // Ensure cache directory exists std::fs::create_dir_all(&cache_dir) .map_err(|e| format!("Failed to create cache directory: {e}"))?; let mut file = std::fs::File::create(&script_path) .map_err(|e| format!("Failed to create PowerShell script file: {e}"))?; file.write_all(&script_content) .map_err(|e| format!("Failed to write PowerShell script data: {e}"))?; drop(file); // Ensure file is closed Ok(script_path) } ================================================ FILE: crates/utils/src/log_msg.rs ================================================ use axum::{extract::ws::Message, response::sse::Event}; use json_patch::Patch; use serde::{Deserialize, Serialize}; pub const EV_STDOUT: &str = "stdout"; pub const EV_STDERR: &str = "stderr"; pub const EV_JSON_PATCH: &str = "json_patch"; pub const EV_SESSION_ID: &str = "session_id"; pub const EV_MESSAGE_ID: &str = "message_id"; pub const EV_READY: &str = "ready"; pub const EV_FINISHED: &str = "finished"; #[derive(Clone, Debug, Serialize, Deserialize)] pub enum LogMsg { Stdout(String), Stderr(String), JsonPatch(Patch), SessionId(String), MessageId(String), Ready, Finished, } impl LogMsg { pub fn name(&self) -> &'static str { match self { LogMsg::Stdout(_) => EV_STDOUT, LogMsg::Stderr(_) => EV_STDERR, LogMsg::JsonPatch(_) => EV_JSON_PATCH, LogMsg::SessionId(_) => EV_SESSION_ID, LogMsg::MessageId(_) => EV_MESSAGE_ID, LogMsg::Ready => EV_READY, LogMsg::Finished => EV_FINISHED, } } pub fn to_sse_event(&self) -> Event { match self { LogMsg::Stdout(s) => Event::default().event(EV_STDOUT).data(s.clone()), LogMsg::Stderr(s) => Event::default().event(EV_STDERR).data(s.clone()), LogMsg::JsonPatch(patch) => { let data = serde_json::to_string(patch).unwrap_or_else(|_| "[]".to_string()); Event::default().event(EV_JSON_PATCH).data(data) } LogMsg::SessionId(s) => Event::default().event(EV_SESSION_ID).data(s.clone()), LogMsg::MessageId(s) => Event::default().event(EV_MESSAGE_ID).data(s.clone()), LogMsg::Ready => Event::default().event(EV_READY).data(""), LogMsg::Finished => Event::default().event(EV_FINISHED).data(""), } } /// Convert LogMsg to WebSocket message with proper error handling pub fn to_ws_message(&self) -> Result { let json = serde_json::to_string(self)?; Ok(Message::Text(json.into())) } /// Convert LogMsg to WebSocket message with fallback error handling /// /// This method mirrors the behavior of the original logmsg_to_ws function /// but with better error handling than unwrap(). pub fn to_ws_message_unchecked(&self) -> Message { // Finished and Ready use special JSON formats for frontend compatibility let json = match self { LogMsg::Ready => r#"{"Ready":true}"#.to_string(), LogMsg::Finished => r#"{"finished":true}"#.to_string(), _ => serde_json::to_string(self) .unwrap_or_else(|_| r#"{"error":"serialization_failed"}"#.to_string()), }; Message::Text(json.into()) } /// Rough size accounting for your byte‑budgeted history. pub fn approx_bytes(&self) -> usize { const OVERHEAD: usize = 8; match self { LogMsg::Stdout(s) => EV_STDOUT.len() + s.len() + OVERHEAD, LogMsg::Stderr(s) => EV_STDERR.len() + s.len() + OVERHEAD, LogMsg::JsonPatch(patch) => { let json_len = serde_json::to_string(patch).map(|s| s.len()).unwrap_or(2); EV_JSON_PATCH.len() + json_len + OVERHEAD } LogMsg::SessionId(s) => EV_SESSION_ID.len() + s.len() + OVERHEAD, LogMsg::MessageId(s) => EV_MESSAGE_ID.len() + s.len() + OVERHEAD, LogMsg::Ready => EV_READY.len() + OVERHEAD, LogMsg::Finished => EV_FINISHED.len() + OVERHEAD, } } } ================================================ FILE: crates/utils/src/msg_store.rs ================================================ use std::{ collections::VecDeque, sync::{Arc, RwLock}, }; use axum::response::sse::Event; use futures::{StreamExt, TryStreamExt, future}; use tokio::{sync::broadcast, task::JoinHandle}; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use crate::{log_msg::LogMsg, stream_lines::LinesStreamExt}; // 100 MB Limit const HISTORY_BYTES: usize = 100000 * 1024; #[derive(Clone)] struct StoredMsg { msg: LogMsg, bytes: usize, } struct Inner { history: VecDeque, total_bytes: usize, } pub struct MsgStore { inner: RwLock, sender: broadcast::Sender, } impl Default for MsgStore { fn default() -> Self { Self::new() } } impl MsgStore { pub fn new() -> Self { let (sender, _) = broadcast::channel(100000); Self { inner: RwLock::new(Inner { history: VecDeque::with_capacity(32), total_bytes: 0, }), sender, } } pub fn push(&self, msg: LogMsg) { let _ = self.sender.send(msg.clone()); // live listeners let bytes = msg.approx_bytes(); let mut inner = self.inner.write().unwrap(); while inner.total_bytes.saturating_add(bytes) > HISTORY_BYTES { if let Some(front) = inner.history.pop_front() { inner.total_bytes = inner.total_bytes.saturating_sub(front.bytes); } else { break; } } inner.history.push_back(StoredMsg { msg, bytes }); inner.total_bytes = inner.total_bytes.saturating_add(bytes); } // Convenience pub fn push_stdout>(&self, s: S) { self.push(LogMsg::Stdout(s.into())); } pub fn push_stderr>(&self, s: S) { self.push(LogMsg::Stderr(s.into())); } pub fn push_patch(&self, patch: json_patch::Patch) { self.push(LogMsg::JsonPatch(patch)); } pub fn push_session_id(&self, session_id: String) { self.push(LogMsg::SessionId(session_id)); } pub fn push_message_id(&self, id: String) { self.push(LogMsg::MessageId(id)); } pub fn push_finished(&self) { self.push(LogMsg::Finished); } pub fn get_receiver(&self) -> broadcast::Receiver { self.sender.subscribe() } pub fn get_history(&self) -> Vec { self.inner .read() .unwrap() .history .iter() .map(|s| s.msg.clone()) .collect() } /// History then live, as `LogMsg`. pub fn history_plus_stream( &self, ) -> futures::stream::BoxStream<'static, Result> { let (history, rx) = (self.get_history(), self.get_receiver()); let hist = futures::stream::iter(history.into_iter().map(Ok::<_, std::io::Error>)); let live = BroadcastStream::new(rx).filter_map(|res| async move { match res { Ok(msg) => Some(Ok(msg)), Err(BroadcastStreamRecvError::Lagged(n)) => { tracing::error!( skipped = n, "MsgStore broadcast lagged. {n} messages dropped for this subscriber" ); None } } }); Box::pin(hist.chain(live)) } pub fn stdout_chunked_stream( &self, ) -> futures::stream::BoxStream<'static, Result> { self.history_plus_stream() .take_while(|res| future::ready(!matches!(res, Ok(LogMsg::Finished)))) .filter_map(|res| async move { match res { Ok(LogMsg::Stdout(s)) => Some(Ok(s)), _ => None, } }) .boxed() } pub fn stdout_lines_stream( &self, ) -> futures::stream::BoxStream<'static, std::io::Result> { self.stdout_chunked_stream().lines() } pub fn stderr_chunked_stream( &self, ) -> futures::stream::BoxStream<'static, Result> { self.history_plus_stream() .take_while(|res| future::ready(!matches!(res, Ok(LogMsg::Finished)))) .filter_map(|res| async move { match res { Ok(LogMsg::Stderr(s)) => Some(Ok(s)), _ => None, } }) .boxed() } pub fn stderr_lines_stream( &self, ) -> futures::stream::BoxStream<'static, std::io::Result> { self.stderr_chunked_stream().lines() } /// Same stream but mapped to `Event` for SSE handlers. pub fn sse_stream(&self) -> futures::stream::BoxStream<'static, Result> { self.history_plus_stream() .map_ok(|m| m.to_sse_event()) .boxed() } /// Forward a stream of typed log messages into this store. pub fn spawn_forwarder(self: Arc, stream: S) -> JoinHandle<()> where S: futures::Stream> + Send + 'static, E: std::fmt::Display + Send + 'static, { tokio::spawn(async move { tokio::pin!(stream); while let Some(next) = stream.next().await { match next { Ok(msg) => self.push(msg), Err(e) => self.push(LogMsg::Stderr(format!("stream error: {e}"))), } } }) } } ================================================ FILE: crates/utils/src/path.rs ================================================ use std::path::{Path, PathBuf}; /// Directory name for storing attachments in worktrees pub const VIBE_ATTACHMENTS_DIR: &str = ".vibe-attachments"; /// Directories that should always be skipped regardless of gitignore. /// .git is not in .gitignore but should never be watched. pub const ALWAYS_SKIP_DIRS: &[&str] = &[".git", "node_modules"]; /// Convert absolute paths to relative paths based on worktree path /// This is a robust implementation that handles symlinks and edge cases pub fn make_path_relative(path: &str, worktree_path: &str) -> String { tracing::trace!("Making path relative: {} -> {}", path, worktree_path); let path_obj = normalize_macos_private_alias(Path::new(&path)); let worktree_path_obj = normalize_macos_private_alias(Path::new(worktree_path)); // If path is already relative, return as is if path_obj.is_relative() { return path.to_string(); } if let Ok(relative_path) = path_obj.strip_prefix(&worktree_path_obj) { let result = relative_path.to_string_lossy().to_string(); tracing::trace!("Successfully made relative: '{}' -> '{}'", path, result); if result.is_empty() { return ".".to_string(); } return result; } if !path_obj.exists() || !worktree_path_obj.exists() { return path.to_string(); } // canonicalize may fail if paths don't exist let canonical_path = std::fs::canonicalize(&path_obj); let canonical_worktree = std::fs::canonicalize(&worktree_path_obj); match (canonical_path, canonical_worktree) { (Ok(canon_path), Ok(canon_worktree)) => { tracing::debug!( "Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'", path, canon_path.display(), worktree_path, canon_worktree.display() ); match canon_path.strip_prefix(&canon_worktree) { Ok(relative_path) => { let result = relative_path.to_string_lossy().to_string(); tracing::debug!( "Successfully made relative with canonical paths: '{}' -> '{}'", path, result ); if result.is_empty() { return ".".to_string(); } result } Err(e) => { tracing::debug!( "Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original", canon_path.display(), canon_worktree.display(), e ); path.to_string() } } } _ => { tracing::debug!( "Could not canonicalize paths (paths may not exist): '{}', '{}', returning original", path, worktree_path ); path.to_string() } } } /// Normalize macOS prefix /private/var/ and /private/tmp/ to their public aliases without resolving paths. /// This allows prefix normalization to work when the full paths don't exist. pub fn normalize_macos_private_alias>(p: P) -> PathBuf { let p = p.as_ref(); if cfg!(target_os = "macos") && let Some(s) = p.to_str() { if s == "/private/var" { return PathBuf::from("/var"); } if let Some(rest) = s.strip_prefix("/private/var/") { return PathBuf::from(format!("/var/{rest}")); } if s == "/private/tmp" { return PathBuf::from("/tmp"); } if let Some(rest) = s.strip_prefix("/private/tmp/") { return PathBuf::from(format!("/tmp/{rest}")); } } p.to_path_buf() } pub fn get_vibe_kanban_temp_dir() -> std::path::PathBuf { let dir_name = if cfg!(debug_assertions) { "vibe-kanban-dev" } else { "vibe-kanban" }; if cfg!(target_os = "macos") { // macOS already uses /var/folders/... which is persistent storage std::env::temp_dir().join(dir_name) } else if cfg!(target_os = "linux") { // Linux: use /var/tmp instead of /tmp to avoid RAM usage std::path::PathBuf::from("/var/tmp").join(dir_name) } else { // Windows and other platforms: use temp dir with vibe-kanban subdirectory std::env::temp_dir().join(dir_name) } } /// Expand leading ~ to user's home directory. pub fn expand_tilde(path_str: &str) -> std::path::PathBuf { shellexpand::tilde(path_str).as_ref().into() } #[cfg(test)] mod tests { use super::*; #[test] fn test_make_path_relative() { // Test with relative path (should remain unchanged) assert_eq!( make_path_relative("src/main.rs", "/tmp/test-worktree"), "src/main.rs" ); // Test with absolute path (should become relative if possible) let test_worktree = "/tmp/test-worktree"; let absolute_path = format!("{test_worktree}/src/main.rs"); let result = make_path_relative(&absolute_path, test_worktree); assert_eq!(result, "src/main.rs"); // Test with path outside worktree (should return original) assert_eq!( make_path_relative("/other/path/file.js", "/tmp/test-worktree"), "/other/path/file.js" ); } #[cfg(target_os = "macos")] #[test] fn test_make_path_relative_macos_private_alias() { // Simulate a worktree under /var with a path reported under /private/var let worktree = "/var/folders/zz/abc123/T/vibe-kanban-dev/worktrees/vk-test"; let path_under_private = format!( "/private/var{}/hello-world.txt", worktree.strip_prefix("/var").unwrap() ); assert_eq!( make_path_relative(&path_under_private, worktree), "hello-world.txt" ); // Also handle the inverse: worktree under /private and path under /var let worktree_private = format!("/private{worktree}"); let path_under_var = format!("{worktree}/hello-world.txt"); assert_eq!( make_path_relative(&path_under_var, &worktree_private), "hello-world.txt" ); } } ================================================ FILE: crates/utils/src/port_file.rs ================================================ use std::{env, path::PathBuf}; use serde::{Deserialize, Serialize}; use tokio::fs; #[derive(Debug, Serialize, Deserialize)] pub struct PortInfo { pub main_port: u16, #[serde(skip_serializing_if = "Option::is_none")] pub preview_proxy_port: Option, } pub async fn write_port_file(port: u16) -> std::io::Result { write_port_file_with_proxy(port, None).await } pub async fn write_port_file_with_proxy( main_port: u16, preview_proxy_port: Option, ) -> std::io::Result { let dir = env::temp_dir().join("vibe-kanban"); let path = dir.join("vibe-kanban.port"); let port_info = PortInfo { main_port, preview_proxy_port, }; let content = serde_json::to_string(&port_info) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; tracing::debug!("Writing ports {:?} to {:?}", port_info, path); fs::create_dir_all(&dir).await?; fs::write(&path, content).await?; Ok(path) } pub async fn read_port_file(app_name: &str) -> std::io::Result { read_port_info(app_name).await.map(|info| info.main_port) } pub async fn read_port_info(app_name: &str) -> std::io::Result { let dir = env::temp_dir().join(app_name); let path = dir.join(format!("{app_name}.port")); tracing::debug!("Reading port from {:?}", path); let content = fs::read_to_string(&path).await?; if let Ok(port_info) = serde_json::from_str::(&content) { return Ok(port_info); } let port: u16 = content .trim() .parse() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; Ok(PortInfo { main_port: port, preview_proxy_port: None, }) } ================================================ FILE: crates/utils/src/process.rs ================================================ use command_group::AsyncGroupChild; #[cfg(unix)] use nix::{ sys::signal::{Signal, killpg}, unistd::{Pid, getpgid}, }; #[cfg(unix)] use tokio::time::Duration; pub async fn kill_process_group(child: &mut AsyncGroupChild) -> std::io::Result<()> { // hit the whole process group, not just the leader #[cfg(unix)] { if let Some(pid) = child.inner().id() { let pgid = getpgid(Some(Pid::from_raw(pid as i32))) .map_err(|e| std::io::Error::other(e.to_string()))?; for sig in [Signal::SIGINT, Signal::SIGTERM, Signal::SIGKILL] { tracing::info!("Sending {:?} to process group {}", sig, pgid); if let Err(e) = killpg(pgid, sig) { tracing::warn!( "Failed to send signal {:?} to process group {}: {}", sig, pgid, e ); } tracing::info!("Waiting 2s for process group {} to exit", pgid); tokio::time::sleep(Duration::from_secs(2)).await; if child.inner().try_wait()?.is_some() { tracing::info!("Process group {} exited after {:?}", pgid, sig); break; } } } } let _ = child.kill().await; let _ = child.wait().await; Ok(()) } ================================================ FILE: crates/utils/src/response.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; #[derive(Debug, Serialize, Deserialize, TS)] pub struct ApiResponse { success: bool, data: Option, error_data: Option, message: Option, } impl ApiResponse { /// Creates a successful response, with `data` and no message. pub fn success(data: T) -> Self { ApiResponse { success: true, data: Some(data), message: None, error_data: None, } } /// Creates an error response, with `message` and no data. pub fn error(message: &str) -> Self { ApiResponse { success: false, data: None, message: Some(message.to_string()), error_data: None, } } /// Creates an error response, with no `data`, no `message`, but with arbitrary `error_data`. pub fn error_with_data(data: E) -> Self { ApiResponse { success: false, data: None, error_data: Some(data), message: None, } } /// Returns true if the response was successful. pub fn is_success(&self) -> bool { self.success } /// Consumes the response and returns the data if present. pub fn into_data(self) -> Option { self.data } /// Returns a reference to the error message if present. pub fn message(&self) -> Option<&str> { self.message.as_deref() } } ================================================ FILE: crates/utils/src/sentry.rs ================================================ use std::sync::OnceLock; use sentry_tracing::{EventFilter, SentryLayer}; use tracing::Level; static INIT_GUARD: OnceLock = OnceLock::new(); #[derive(Clone, Copy, Debug)] pub enum SentrySource { Backend, Desktop, Mcp, Remote, } impl SentrySource { fn tag(self) -> &'static str { match self { SentrySource::Backend => "backend", SentrySource::Desktop => "desktop", SentrySource::Mcp => "mcp", SentrySource::Remote => "remote", } } fn dsn(self) -> Option { let value = match self { SentrySource::Remote => option_env!("SENTRY_DSN_REMOTE") .map(|s| s.to_string()) .or_else(|| std::env::var("SENTRY_DSN_REMOTE").ok()), _ => option_env!("SENTRY_DSN") .map(|s| s.to_string()) .or_else(|| std::env::var("SENTRY_DSN").ok()), }; value.filter(|s| !s.is_empty()) } } fn environment() -> &'static str { if cfg!(debug_assertions) { "dev" } else { "production" } } pub fn init_once(source: SentrySource) { let Some(dsn) = source.dsn() else { return; }; INIT_GUARD.get_or_init(|| { sentry::init(( dsn, sentry::ClientOptions { release: sentry::release_name!(), environment: Some(environment().into()), ..Default::default() }, )) }); sentry::configure_scope(|scope| { scope.set_tag("source", source.tag()); }); } pub fn configure_user_scope(user_id: &str, username: Option<&str>, email: Option<&str>) { let mut sentry_user = sentry::User { id: Some(user_id.to_string()), ..Default::default() }; if let Some(username) = username { sentry_user.username = Some(username.to_string()); } if let Some(email) = email { sentry_user.email = Some(email.to_string()); } sentry::configure_scope(|scope| { scope.set_user(Some(sentry_user)); }); } pub fn sentry_layer() -> SentryLayer where S: tracing::Subscriber, S: for<'a> tracing_subscriber::registry::LookupSpan<'a>, { SentryLayer::default() .span_filter(|meta| { matches!( *meta.level(), Level::DEBUG | Level::INFO | Level::WARN | Level::ERROR ) }) .event_filter(|meta| match *meta.level() { Level::ERROR => EventFilter::Event, Level::DEBUG | Level::INFO | Level::WARN => EventFilter::Breadcrumb, Level::TRACE => EventFilter::Ignore, }) } ================================================ FILE: crates/utils/src/shell.rs ================================================ //! Cross-platform shell command utilities use std::{ collections::HashSet, env::{join_paths, split_paths}, ffi::{OsStr, OsString}, path::{Path, PathBuf}, }; use crate::tokio::block_on; /// Returns the appropriate shell command and argument for the current platform. /// /// Returns (shell_program, shell_arg) where: /// - Windows: ("cmd", "/C") /// - Unix-like: ("sh", "-c") or ("bash", "-c") if available pub fn get_shell_command() -> (String, &'static str) { if cfg!(windows) { ("cmd".into(), "/C") } else { UnixShell::current_shell().get_shell_command() } } /// Returns the path to an interactive shell for the current platform. /// Used for spawning PTY sessions. /// /// On Windows, prefers PowerShell if available, falling back to cmd.exe. /// On Unix, returns the user's configured shell from $SHELL. pub async fn get_interactive_shell() -> PathBuf { if cfg!(windows) { // Prefer PowerShell if available, fall back to cmd.exe if let Some(powershell) = resolve_executable_path("powershell.exe").await { powershell } else { PathBuf::from("cmd.exe") } } else { UnixShell::current_shell().path().to_path_buf() } } /// Resolve an executable by name, falling back to a refreshed PATH if needed. /// /// The search order is: /// 1. Explicit paths (absolute or containing a separator). /// 2. The current process PATH via `which`. /// 3. A platform-specific refresh of PATH (login shell on Unix, PowerShell on Windows), /// after which we re-run the `which` lookup and update the process PATH for future calls. pub async fn resolve_executable_path(executable: &str) -> Option { if executable.trim().is_empty() { return None; } let path = Path::new(executable); if path.is_absolute() && path.is_file() { return Some(path.to_path_buf()); } if let Some(found) = which(executable).await { return Some(found); } if refresh_path().await && let Some(found) = which(executable).await { return Some(found); } None } pub fn resolve_executable_path_blocking(executable: &str) -> Option { block_on(resolve_executable_path(executable)) } /// Merge two PATH strings into a single, de-duplicated PATH. /// /// - Keeps the order of entries from `primary`. /// - Appends only *unseen* entries from `secondary`. /// - Ignores empty components. /// - Returns a platform-correct PATH string (using the OS separator). pub fn merge_paths(primary: impl AsRef, secondary: impl AsRef) -> OsString { let mut seen = HashSet::::new(); let mut merged = Vec::::new(); for p in split_paths(primary.as_ref()).chain(split_paths(secondary.as_ref())) { if !p.as_os_str().is_empty() && seen.insert(p.clone()) { merged.push(p); } } join_paths(merged).unwrap_or_default() } async fn refresh_path() -> bool { let Some(refreshed) = get_fresh_path().await else { return false; }; let existing = std::env::var_os("PATH").unwrap_or_default(); let refreshed_os = OsString::from(&refreshed); let merged = merge_paths(&existing, refreshed_os); if merged == existing { return false; } tracing::debug!(?existing, ?refreshed, ?merged, "Refreshed PATH"); unsafe { std::env::set_var("PATH", &merged); } true } async fn which(executable: &str) -> Option { let executable = executable.to_string(); tokio::task::spawn_blocking(move || which::which(executable)) .await .ok() .and_then(|result| result.ok()) } #[derive(Debug, Clone, PartialEq)] pub enum UnixShell { Zsh(PathBuf), Bash(PathBuf), Sh(PathBuf), Other(PathBuf), } impl UnixShell { pub fn path(&self) -> &Path { match self { UnixShell::Zsh(p) | UnixShell::Bash(p) | UnixShell::Sh(p) | UnixShell::Other(p) => p, } } pub fn login(&self) -> bool { matches!(self, UnixShell::Zsh(_) | UnixShell::Bash(_)) } pub fn config_file(&self) -> Option { let home = dirs::home_dir()?; let config_file = match self { UnixShell::Zsh(_) => Some(home.join(".zshrc")), UnixShell::Bash(_) => Some(home.join(".bashrc")), UnixShell::Sh(_) | UnixShell::Other(_) => None, }; config_file.filter(|p| p.is_file()) } pub fn source_command(&self) -> Option { if let Some(source_file) = self.config_file() && let Ok(escaped_source_file) = shlex::try_quote(source_file.to_string_lossy().as_ref()) { Some(format!("source {escaped_source_file}")) } else { None } } pub fn current_shell() -> UnixShell { if let Ok(shell) = std::env::var("SHELL") && let Some(shell) = UnixShell::from_path(Path::new(&shell)) { return shell; } UnixShell::Sh(PathBuf::from("/bin/sh")) } pub fn from_path(path: &Path) -> Option { if path.is_absolute() && path.is_file() { let path_buf = path.to_path_buf(); if path.file_name() == Some(OsStr::new("zsh")) { Some(UnixShell::Zsh(path_buf)) } else if path.file_name() == Some(OsStr::new("bash")) { Some(UnixShell::Bash(path_buf)) } else if path.file_name() == Some(OsStr::new("sh")) { Some(UnixShell::Sh(path_buf)) } else { Some(UnixShell::Other(path_buf)) } } else { None } } pub fn get_shell_command(&self) -> (String, &'static str) { (self.path().to_string_lossy().into_owned(), "-c") } } #[cfg(not(windows))] async fn get_fresh_path() -> Option { use std::{process::Stdio, time::Duration}; use tokio::process::Command; async fn run(shell: &UnixShell) -> Option { let mut cmd = Command::new(shell.path()); if shell.login() { cmd.arg("-l"); } if let Some(source_command) = shell.source_command() { cmd.arg("-c") .arg(format!("{source_command}; printf '%s' \"$PATH\"")); } else { cmd.arg("-c").arg("printf '%s' \"$PATH\""); } cmd.env("TERM", "dumb") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); const PATH_REFRESH_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); let child = cmd.spawn().ok()?; let output = match tokio::time::timeout( PATH_REFRESH_COMMAND_TIMEOUT, child.wait_with_output(), ) .await { Ok(Ok(output)) => output, Ok(Err(err)) => { tracing::debug!( shell = %shell.path().display(), ?err, "Failed to retrieve PATH from login shell" ); return None; } Err(_) => { tracing::warn!( shell = %shell.path().display(), timeout_secs = PATH_REFRESH_COMMAND_TIMEOUT.as_secs(), "Timed out retrieving PATH from login shell" ); return None; } }; if !output.status.success() { return None; } let path = String::from_utf8(output.stdout).ok()?.trim().to_string(); if path.is_empty() { None } else { Some(path) } } let mut paths = Vec::new(); let current_shell = UnixShell::current_shell(); if let Some(path) = run(¤t_shell).await { paths.push(path); } let shells: Vec = ["/bin/zsh", "/bin/bash", "/bin/sh"] .into_iter() .filter_map(|p| UnixShell::from_path(Path::new(p))) .collect(); for shell in shells { if !(shell == current_shell) && let Some(path) = run(&shell).await { paths.push(path); } } if paths.is_empty() { return None; } paths .into_iter() .map(OsString::from) .reduce(|a, b| merge_paths(&a, &b)) .map(|merged| merged.to_string_lossy().into_owned()) } #[cfg(windows)] async fn get_fresh_path() -> Option { tokio::task::spawn_blocking(get_fresh_path_blocking) .await .ok() .flatten() } #[cfg(windows)] fn get_fresh_path_blocking() -> Option { use std::{ ffi::{OsStr, OsString}, os::windows::ffi::{OsStrExt, OsStringExt}, }; use winreg::{HKEY, RegKey, enums::*}; // Expand %VARS% for registry PATH entries fn expand_env_vars(input: &OsStr) -> OsString { use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW; let wide: Vec = input.encode_wide().chain(Some(0)).collect(); unsafe { let needed = ExpandEnvironmentStringsW(wide.as_ptr(), std::ptr::null_mut(), 0); if needed == 0 { return input.to_os_string(); } let mut buf = vec![0u16; needed as usize]; let written = ExpandEnvironmentStringsW(wide.as_ptr(), buf.as_mut_ptr(), needed); if written == 0 { return input.to_os_string(); } // written includes the trailing NUL when it fits OsString::from_wide(&buf[..(written as usize).saturating_sub(1)]) } } fn read_registry_path(root: HKEY, subkey: &str) -> Option { let key = RegKey::predef(root) .open_subkey_with_flags(subkey, KEY_READ) .ok()?; key.get_value::("Path").ok().map(OsString::from) } let mut paths: Vec = Vec::new(); if let Some(user_path) = read_registry_path(HKEY_CURRENT_USER, "Environment") { paths.push(expand_env_vars(&user_path)); } if let Some(machine_path) = read_registry_path( HKEY_LOCAL_MACHINE, r"System\CurrentControlSet\Control\Session Manager\Environment", ) { paths.push(expand_env_vars(&machine_path)); } if paths.is_empty() { return None; } paths .into_iter() .map(OsString::from) .reduce(|a, b| merge_paths(&a, &b)) .map(|merged| merged.to_string_lossy().into_owned()) } ================================================ FILE: crates/utils/src/stream_lines.rs ================================================ use bytes::Bytes; use futures::{Stream, StreamExt, TryStreamExt}; use tokio_util::{ codec::{FramedRead, LinesCodec}, io::StreamReader, }; /// Extension trait for converting chunked string streams to line streams. pub trait LinesStreamExt: Stream> + Sized { /// Convert a chunked string stream to a line stream. fn lines(self) -> futures::stream::BoxStream<'static, std::io::Result> where Self: Send + 'static, { let reader = StreamReader::new(self.map(|result| result.map(Bytes::from))); FramedRead::new(reader, LinesCodec::new()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) .boxed() } } impl LinesStreamExt for S where S: Stream> {} ================================================ FILE: crates/utils/src/text.rs ================================================ use regex::Regex; use uuid::Uuid; pub fn git_branch_id(input: &str) -> String { // 1. lowercase let lower = input.to_lowercase(); // 2. replace non-alphanumerics with hyphens let re = Regex::new(r"[^a-z0-9]+").unwrap(); let slug = re.replace_all(&lower, "-"); // 3. trim extra hyphens let trimmed = slug.trim_matches('-'); // 4. take up to 16 chars, then trim trailing hyphens again let cut: String = trimmed.chars().take(16).collect(); cut.trim_end_matches('-').to_string() } pub fn short_uuid(u: &Uuid) -> String { // to_simple() gives you a 32-char hex string with no hyphens let full = u.simple().to_string(); full.chars().take(4).collect() // grab the first 4 chars } pub fn truncate_to_char_boundary(content: &str, max_len: usize) -> &str { if content.len() <= max_len { return content; } let cutoff = content .char_indices() .map(|(idx, _)| idx) .chain(std::iter::once(content.len())) .take_while(|&idx| idx <= max_len) .last() .unwrap_or(0); debug_assert!(content.is_char_boundary(cutoff)); &content[..cutoff] } #[cfg(test)] mod tests { #[test] fn test_truncate_to_char_boundary() { use super::truncate_to_char_boundary; let input = "a".repeat(10); assert_eq!(truncate_to_char_boundary(&input, 7), "a".repeat(7)); let input = "hello world"; assert_eq!(truncate_to_char_boundary(input, input.len()), input); let input = "🔥🔥🔥"; // each fire emoji is 4 bytes assert_eq!(truncate_to_char_boundary(input, 5), "🔥"); assert_eq!(truncate_to_char_boundary(input, 3), ""); } } ================================================ FILE: crates/utils/src/tokio.rs ================================================ use std::{future::Future, sync::OnceLock}; use tokio::runtime::{Builder, Handle, Runtime, RuntimeFlavor}; fn rt() -> &'static Runtime { static RT: OnceLock = OnceLock::new(); RT.get_or_init(|| { Builder::new_multi_thread() .enable_all() .build() .expect("failed to build global Tokio runtime") }) } /// Run an async future from sync code safely. /// If already inside a Tokio runtime, it will use that runtime. pub fn block_on(fut: F) -> T where F: Future + Send, T: Send, { match Handle::try_current() { // Already inside a Tokio runtime Ok(h) => match h.runtime_flavor() { // Use block_in_place so other tasks keep running. RuntimeFlavor::MultiThread => tokio::task::block_in_place(|| rt().block_on(fut)), // Spawn a new thread to avoid freezing a single-thread runtime. RuntimeFlavor::CurrentThread | _ => std::thread::scope(|s| { s.spawn(|| rt().block_on(fut)) .join() .expect("thread panicked") }), }, // Outside Tokio: block normally. Err(_) => rt().block_on(fut), } } ================================================ FILE: crates/utils/src/version.rs ================================================ /// The current application version from Cargo.toml pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); ================================================ FILE: crates/workspace-manager/Cargo.toml ================================================ [package] name = "workspace-manager" version = "0.1.33" edition = "2024" [dependencies] db = { path = "../db" } git = { path = "../git" } utils = { path = "../utils" } worktree-manager = { path = "../worktree-manager" } sqlx = "0.8.6" tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uuid = { version = "1.0", features = ["v4", "serde"] } ================================================ FILE: crates/workspace-manager/src/lib.rs ================================================ mod workspace_manager; pub use workspace_manager::{ ManagedWorkspace, RepoWorkspaceInput, RepoWorktree, WorkspaceDeletionContext, WorkspaceError, WorkspaceManager, WorktreeContainer, }; ================================================ FILE: crates/workspace-manager/src/workspace_manager.rs ================================================ use std::path::{Path, PathBuf}; use db::{ DBService, models::{ file::WorkspaceAttachment, repo::{Repo, RepoError}, requests::WorkspaceRepoInput, session::Session, workspace::Workspace as DbWorkspace, workspace_repo::{CreateWorkspaceRepo, RepoWithTargetBranch, WorkspaceRepo}, }, }; use git::{GitService, GitServiceError}; use thiserror::Error; use tracing::{debug, error, info, warn}; use uuid::Uuid; use worktree_manager::{WorktreeCleanup, WorktreeError, WorktreeManager}; #[derive(Debug, Clone)] pub struct RepoWorkspaceInput { pub repo: Repo, pub target_branch: String, } impl RepoWorkspaceInput { pub fn new(repo: Repo, target_branch: String) -> Self { Self { repo, target_branch, } } } #[derive(Debug, Error)] pub enum WorkspaceError { #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Repo(#[from] RepoError), #[error(transparent)] Worktree(#[from] WorktreeError), #[error(transparent)] GitService(#[from] GitServiceError), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Workspace not found")] WorkspaceNotFound, #[error("Repository already attached to workspace")] RepoAlreadyAttached, #[error("Branch '{branch}' does not exist in repository '{repo_name}'")] BranchNotFound { repo_name: String, branch: String }, #[error("No repositories provided")] NoRepositories, #[error("Partial workspace creation failed: {0}")] PartialCreation(String), } /// Info about a single repo's worktree within a workspace #[derive(Debug, Clone)] pub struct RepoWorktree { pub repo_id: Uuid, pub repo_name: String, pub source_repo_path: PathBuf, pub worktree_path: PathBuf, } /// A container directory holding worktrees for all project repos #[derive(Debug, Clone)] pub struct WorktreeContainer { pub workspace_dir: PathBuf, pub worktrees: Vec, } #[derive(Debug, Clone)] pub struct WorkspaceDeletionContext { pub workspace_id: Uuid, pub branch_name: String, pub workspace_dir: Option, pub repositories: Vec, pub repo_paths: Vec, pub session_ids: Vec, } #[derive(Clone)] pub struct ManagedWorkspace { pub workspace: DbWorkspace, pub repos: Vec, db: DBService, } impl ManagedWorkspace { fn new(db: DBService, workspace: DbWorkspace, repos: Vec) -> Self { Self { workspace, repos, db, } } async fn attach_repository(&self, repo: &WorkspaceRepoInput) -> Result<(), sqlx::Error> { let create_repo = CreateWorkspaceRepo { repo_id: repo.repo_id, target_branch: repo.target_branch.clone(), }; WorkspaceRepo::create_many( &self.db.pool, self.workspace.id, std::slice::from_ref(&create_repo), ) .await .map(|_| ()) } async fn refresh(&mut self) -> Result<(), WorkspaceError> { self.workspace = DbWorkspace::find_by_id(&self.db.pool, self.workspace.id) .await? .ok_or(WorkspaceError::WorkspaceNotFound)?; self.repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace( &self.db.pool, self.workspace.id, ) .await?; Ok(()) } pub async fn add_repository( &mut self, repo_ref: &WorkspaceRepoInput, git: &GitService, ) -> Result<(), WorkspaceError> { let repo = Repo::find_by_id(&self.db.pool, repo_ref.repo_id) .await? .ok_or(RepoError::NotFound)?; if !git.check_branch_exists(&repo.path, &repo_ref.target_branch)? { return Err(WorkspaceError::BranchNotFound { repo_name: repo.name, branch: repo_ref.target_branch.clone(), }); } if WorkspaceRepo::find_by_workspace_and_repo_id( &self.db.pool, self.workspace.id, repo_ref.repo_id, ) .await? .is_some() { return Err(WorkspaceError::RepoAlreadyAttached); } self.attach_repository(repo_ref).await?; self.refresh().await?; Ok(()) } pub async fn associate_attachments(&self, attachment_ids: &[Uuid]) -> Result<(), sqlx::Error> { if attachment_ids.is_empty() { return Ok(()); } WorkspaceAttachment::associate_many_dedup(&self.db.pool, self.workspace.id, attachment_ids) .await } pub async fn prepare_deletion_context(&self) -> Result { let repositories = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, self.workspace.id).await?; let session_ids = Session::find_by_workspace_id(&self.db.pool, self.workspace.id) .await? .into_iter() .map(|session| session.id) .collect::>(); let repo_paths = repositories .iter() .map(|repo| repo.path.clone()) .collect::>(); Ok(WorkspaceDeletionContext { workspace_id: self.workspace.id, branch_name: self.workspace.branch.clone(), workspace_dir: self.workspace.container_ref.clone().map(PathBuf::from), repositories, repo_paths, session_ids, }) } pub async fn delete_record(&self) -> Result { DbWorkspace::delete(&self.db.pool, self.workspace.id).await } } #[derive(Clone)] pub struct WorkspaceManager { db: DBService, } impl WorkspaceManager { pub fn new(db: DBService) -> Self { Self { db } } pub async fn load_managed_workspace( &self, workspace: DbWorkspace, ) -> Result { let repos = WorkspaceRepo::find_repos_with_target_branch_for_workspace(&self.db.pool, workspace.id) .await?; Ok(ManagedWorkspace::new(self.db.clone(), workspace, repos)) } pub fn spawn_workspace_deletion_cleanup( context: WorkspaceDeletionContext, delete_branches: bool, ) { tokio::spawn(async move { let WorkspaceDeletionContext { workspace_id, branch_name, workspace_dir, repositories, repo_paths, session_ids, } = context; for session_id in session_ids { if let Err(e) = Self::remove_session_process_logs(session_id).await { warn!( "Failed to remove filesystem process logs for session {}: {}", session_id, e ); } } if let Some(workspace_dir) = workspace_dir { info!( "Starting background cleanup for workspace {} at {}", workspace_id, workspace_dir.display() ); if let Err(e) = Self::cleanup_workspace(&workspace_dir, &repositories).await { error!( "Background workspace cleanup failed for {} at {}: {}", workspace_id, workspace_dir.display(), e ); } else { info!( "Background cleanup completed for workspace {}", workspace_id ); } } if delete_branches { let git_service = GitService::new(); for repo_path in repo_paths { match git_service.delete_branch(&repo_path, &branch_name) { Ok(()) => { info!("Deleted branch '{}' from repo {:?}", branch_name, repo_path); } Err(e) => { warn!( "Failed to delete branch '{}' from repo {:?}: {}", branch_name, repo_path, e ); } } } } }); } async fn remove_session_process_logs(session_id: Uuid) -> Result<(), std::io::Error> { let dir = utils::execution_logs::process_logs_session_dir(session_id); match tokio::fs::remove_dir_all(&dir).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e), } } /// Create a workspace with worktrees for all repositories. /// On failure, rolls back any already-created worktrees. pub async fn create_workspace( workspace_dir: &Path, repos: &[RepoWorkspaceInput], branch_name: &str, ) -> Result { if repos.is_empty() { return Err(WorkspaceError::NoRepositories); } info!( "Creating workspace at {} with {} repositories", workspace_dir.display(), repos.len() ); tokio::fs::create_dir_all(workspace_dir).await?; let mut created_worktrees: Vec = Vec::new(); for input in repos { let worktree_path = workspace_dir.join(&input.repo.name); debug!( "Creating worktree for repo '{}' at {}", input.repo.name, worktree_path.display() ); match WorktreeManager::create_worktree( &input.repo.path, branch_name, &worktree_path, &input.target_branch, true, ) .await { Ok(()) => { created_worktrees.push(RepoWorktree { repo_id: input.repo.id, repo_name: input.repo.name.clone(), source_repo_path: input.repo.path.clone(), worktree_path, }); } Err(e) => { error!( "Failed to create worktree for repo '{}': {}. Rolling back...", input.repo.name, e ); // Rollback: cleanup all worktrees we've created so far Self::cleanup_created_worktrees(&created_worktrees).await; // Also remove the workspace directory if it's empty if let Err(cleanup_err) = tokio::fs::remove_dir(workspace_dir).await { debug!( "Could not remove workspace dir during rollback: {}", cleanup_err ); } return Err(WorkspaceError::PartialCreation(format!( "Failed to create worktree for repo '{}': {}", input.repo.name, e ))); } } } info!( "Successfully created workspace with {} worktrees", created_worktrees.len() ); Ok(WorktreeContainer { workspace_dir: workspace_dir.to_path_buf(), worktrees: created_worktrees, }) } /// Ensure all worktrees in a workspace exist (for cold restart scenarios) pub async fn ensure_workspace_exists( workspace_dir: &Path, repos: &[RepoWorkspaceInput], branch_name: &str, ) -> Result<(), WorkspaceError> { if repos.is_empty() { return Err(WorkspaceError::NoRepositories); } // Try legacy migration first (single repo projects only) // Old layout had worktree directly at workspace_dir; new layout has it at workspace_dir/{repo_name} if repos.len() == 1 && Self::migrate_legacy_worktree(workspace_dir, &repos[0].repo).await? { return Ok(()); } if !workspace_dir.exists() { tokio::fs::create_dir_all(workspace_dir).await?; } let git = GitService::new(); for input in repos { let repo = &input.repo; let worktree_path = workspace_dir.join(&repo.name); debug!( "Ensuring worktree exists for repo '{}' at {}", repo.name, worktree_path.display() ); if git.check_branch_exists(&repo.path, branch_name)? { WorktreeManager::ensure_worktree_exists(&repo.path, branch_name, &worktree_path) .await?; } else { info!( "Workspace branch '{}' missing in repo '{}'; creating from target branch '{}'", branch_name, repo.name, input.target_branch ); WorktreeManager::create_worktree( &repo.path, branch_name, &worktree_path, &input.target_branch, true, ) .await?; } } Ok(()) } /// Clean up all worktrees in a workspace pub async fn cleanup_workspace( workspace_dir: &Path, repos: &[Repo], ) -> Result<(), WorkspaceError> { info!("Cleaning up workspace at {}", workspace_dir.display()); let cleanup_data: Vec = repos .iter() .map(|repo| { let worktree_path = workspace_dir.join(&repo.name); WorktreeCleanup::new(worktree_path, Some(repo.path.clone())) }) .collect(); WorktreeManager::batch_cleanup_worktrees(&cleanup_data).await?; // Remove the workspace directory itself if workspace_dir.exists() && let Err(e) = tokio::fs::remove_dir_all(workspace_dir).await { debug!( "Could not remove workspace directory {}: {}", workspace_dir.display(), e ); } Ok(()) } /// Get the base directory for workspaces (same as worktree base dir) pub fn get_workspace_base_dir() -> PathBuf { WorktreeManager::get_worktree_base_dir() } /// Migrate a legacy single-worktree layout to the new workspace layout. /// Old layout: workspace_dir IS the worktree /// New layout: workspace_dir contains worktrees at workspace_dir/{repo_name} /// /// Returns Ok(true) if migration was performed, Ok(false) if no migration needed. pub async fn migrate_legacy_worktree( workspace_dir: &Path, repo: &Repo, ) -> Result { let expected_worktree_path = workspace_dir.join(&repo.name); // Detect old-style: workspace_dir exists AND has .git file (worktree marker) // AND expected new location doesn't exist let git_file = workspace_dir.join(".git"); let is_old_style = workspace_dir.exists() && git_file.exists() && git_file.is_file() // .git file = worktree, .git dir = main repo && !expected_worktree_path.exists(); if !is_old_style { return Ok(false); } info!( "Detected legacy worktree at {}, migrating to new layout", workspace_dir.display() ); // Move old worktree to temp location (can't move into subdirectory of itself) let temp_name = format!( "{}-migrating", workspace_dir .file_name() .map(|n| n.to_string_lossy()) .unwrap_or_default() ); let temp_path = workspace_dir.with_file_name(temp_name); WorktreeManager::move_worktree(&repo.path, workspace_dir, &temp_path).await?; // Create new workspace directory tokio::fs::create_dir_all(workspace_dir).await?; // Move worktree to final location using git worktree move WorktreeManager::move_worktree(&repo.path, &temp_path, &expected_worktree_path).await?; if temp_path.exists() { let _ = tokio::fs::remove_dir_all(&temp_path).await; } info!( "Successfully migrated legacy worktree to {}", expected_worktree_path.display() ); Ok(true) } /// Helper to cleanup worktrees during rollback async fn cleanup_created_worktrees(worktrees: &[RepoWorktree]) { for worktree in worktrees { let cleanup = WorktreeCleanup::new( worktree.worktree_path.clone(), Some(worktree.source_repo_path.clone()), ); if let Err(e) = WorktreeManager::cleanup_worktree(&cleanup).await { error!( "Failed to cleanup worktree '{}' during rollback: {}", worktree.repo_name, e ); } } } pub async fn cleanup_orphan_workspaces(&self) { if std::env::var("DISABLE_WORKTREE_CLEANUP").is_ok() { info!( "Orphan workspace cleanup is disabled via DISABLE_WORKTREE_CLEANUP environment variable" ); return; } // Always clean up the default directory let default_dir = WorktreeManager::get_default_worktree_base_dir(); self.cleanup_orphans_in_directory(&default_dir).await; // Also clean up custom directory if it's different from the default let current_dir = Self::get_workspace_base_dir(); if current_dir != default_dir { self.cleanup_orphans_in_directory(¤t_dir).await; } } async fn cleanup_orphans_in_directory(&self, workspace_base_dir: &Path) { if !workspace_base_dir.exists() { debug!( "Workspace base directory {} does not exist, skipping orphan cleanup", workspace_base_dir.display() ); return; } let entries = match std::fs::read_dir(workspace_base_dir) { Ok(entries) => entries, Err(e) => { error!( "Failed to read workspace base directory {}: {}", workspace_base_dir.display(), e ); return; } }; for entry in entries { let entry = match entry { Ok(entry) => entry, Err(e) => { warn!("Failed to read directory entry: {}", e); continue; } }; let path = entry.path(); if !path.is_dir() { continue; } let workspace_path_str = path.to_string_lossy().to_string(); if let Ok(false) = DbWorkspace::container_ref_exists(&self.db.pool, &workspace_path_str).await { info!("Found orphaned workspace: {}", workspace_path_str); if let Err(e) = Self::cleanup_workspace_without_repos(&path).await { error!( "Failed to remove orphaned workspace {}: {}", workspace_path_str, e ); } else { info!( "Successfully removed orphaned workspace: {}", workspace_path_str ); } } } } async fn cleanup_workspace_without_repos(workspace_dir: &Path) -> Result<(), WorkspaceError> { info!( "Cleaning up orphaned workspace at {}", workspace_dir.display() ); let entries = match std::fs::read_dir(workspace_dir) { Ok(entries) => entries, Err(e) => { debug!( "Cannot read workspace directory {}, attempting direct removal: {}", workspace_dir.display(), e ); return tokio::fs::remove_dir_all(workspace_dir) .await .map_err(WorkspaceError::Io); } }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_dir() && let Err(e) = WorktreeManager::cleanup_suspected_worktree(&path).await { warn!("Failed to cleanup suspected worktree: {}", e); } } if workspace_dir.exists() && let Err(e) = tokio::fs::remove_dir_all(workspace_dir).await { debug!( "Could not remove workspace directory {}: {}", workspace_dir.display(), e ); } Ok(()) } } ================================================ FILE: crates/worktree-manager/Cargo.toml ================================================ [package] name = "worktree-manager" version = "0.1.33" edition = "2024" [dependencies] git = { path = "../git" } utils = { path = "../utils" } tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } git2 = { workspace = true } dunce = "1.0" [dev-dependencies] tempfile = "3.21" ================================================ FILE: crates/worktree-manager/src/lib.rs ================================================ mod worktree_manager; pub use worktree_manager::{WorktreeCleanup, WorktreeError, WorktreeManager}; ================================================ FILE: crates/worktree-manager/src/worktree_manager.rs ================================================ use std::{ collections::HashMap, fs, path::{Path, PathBuf}, sync::{Arc, LazyLock, Mutex, OnceLock}, }; static WORKSPACE_DIR_OVERRIDE: OnceLock = OnceLock::new(); use git::{GitService, GitServiceError}; use git2::{Error as GitError, Repository}; use thiserror::Error; use tracing::{debug, info, trace}; use utils::{path::normalize_macos_private_alias, shell::resolve_executable_path}; // Global synchronization for worktree creation to prevent race conditions static WORKTREE_CREATION_LOCKS: LazyLock>>>> = LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone)] pub struct WorktreeCleanup { pub worktree_path: PathBuf, pub git_repo_path: Option, } impl WorktreeCleanup { pub fn new(worktree_path: PathBuf, git_repo_path: Option) -> Self { Self { worktree_path, git_repo_path, } } } #[derive(Debug, Error)] pub enum WorktreeError { #[error(transparent)] Git(#[from] GitError), #[error(transparent)] GitService(#[from] GitServiceError), #[error("Git CLI error: {0}")] GitCli(String), #[error("Task join error: {0}")] TaskJoin(String), #[error("Invalid path: {0}")] InvalidPath(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Branch not found: {0}")] BranchNotFound(String), #[error("Repository error: {0}")] Repository(String), } pub struct WorktreeManager; impl WorktreeManager { pub fn set_workspace_dir_override(path: PathBuf) { let _ = WORKSPACE_DIR_OVERRIDE.set(path); } /// Create a worktree with a new branch pub async fn create_worktree( repo_path: &Path, branch_name: &str, worktree_path: &Path, base_branch: &str, create_branch: bool, ) -> Result<(), WorktreeError> { if create_branch { let repo_path_owned = repo_path.to_path_buf(); let branch_name_owned = branch_name.to_string(); let base_branch_owned = base_branch.to_string(); tokio::task::spawn_blocking(move || { let repo = Repository::open(&repo_path_owned)?; let base_branch_ref = GitService::find_branch(&repo, &base_branch_owned)?.into_reference(); repo.branch( &branch_name_owned, &base_branch_ref.peel_to_commit()?, false, )?; Ok::<(), GitServiceError>(()) }) .await .map_err(|e| WorktreeError::TaskJoin(format!("Task join error: {e}")))??; } Self::ensure_worktree_exists(repo_path, branch_name, worktree_path).await } /// Ensure worktree exists, recreating if necessary with proper synchronization /// This is the main entry point for ensuring a worktree exists and prevents race conditions pub async fn ensure_worktree_exists( repo_path: &Path, branch_name: &str, worktree_path: &Path, ) -> Result<(), WorktreeError> { let path_str = worktree_path.to_string_lossy().to_string(); // Get or create a lock for this specific worktree path let lock = { let mut locks = WORKTREE_CREATION_LOCKS.lock().unwrap(); locks .entry(path_str.clone()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() }; // Acquire the lock for this specific worktree path let _guard = lock.lock().await; // Check if worktree already exists and is properly set up if Self::is_worktree_properly_set_up(repo_path, worktree_path).await? { trace!("Worktree already properly set up at path: {}", path_str); return Ok(()); } // If worktree doesn't exist or isn't properly set up, recreate it info!("Worktree needs recreation at path: {}", path_str); Self::recreate_worktree_internal(repo_path, branch_name, worktree_path).await } /// Internal worktree recreation function (always recreates) async fn recreate_worktree_internal( repo_path: &Path, branch_name: &str, worktree_path: &Path, ) -> Result<(), WorktreeError> { let path_str = worktree_path.to_string_lossy().to_string(); let branch_name_owned = branch_name.to_string(); let worktree_path_owned = worktree_path.to_path_buf(); info!( "Creating worktree {} at path {}", branch_name_owned, path_str ); // Step 1: Comprehensive cleanup of existing worktree and metadata (non-blocking) Self::comprehensive_worktree_cleanup_async(repo_path, &worktree_path_owned).await?; // Step 2: Ensure parent directory exists (non-blocking) if let Some(parent) = worktree_path_owned.parent() { let parent_path = parent.to_path_buf(); tokio::task::spawn_blocking(move || std::fs::create_dir_all(&parent_path)) .await .map_err(|e| WorktreeError::TaskJoin(format!("Task join error: {e}")))? .map_err(WorktreeError::Io)?; } // Step 3: Create the worktree with retry logic for metadata conflicts (non-blocking) Self::create_worktree_with_retry( repo_path, &branch_name_owned, &worktree_path_owned, &path_str, ) .await } /// Check if a worktree is properly set up (filesystem + git metadata) async fn is_worktree_properly_set_up( repo_path: &Path, worktree_path: &Path, ) -> Result { let repo_path = repo_path.to_path_buf(); let worktree_path = worktree_path.to_path_buf(); tokio::task::spawn_blocking(move || -> Result { // Check 1: Filesystem path must exist if !worktree_path.exists() { return Ok(false); } // Check 2: Worktree must be registered in git metadata using find_worktree let repo = Repository::open(&repo_path).map_err(WorktreeError::Git)?; let Some(worktree_name) = Self::find_worktree_git_internal_name(&repo_path, &worktree_path)? else { // Directory exists but not registered in git metadata - needs recreation return Ok(false); }; // Try to find the worktree - if it exists and is valid, we're good match repo.find_worktree(&worktree_name) { Ok(_) => Ok(true), Err(_) => Ok(false), } }) .await .map_err(|e| WorktreeError::TaskJoin(format!("{e}")))? } fn find_worktree_git_internal_name( git_repo_path: &Path, worktree_path: &Path, ) -> Result, WorktreeError> { fn canonicalize_for_compare(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } let worktree_root = canonicalize_for_compare(&normalize_macos_private_alias(worktree_path)); let worktree_metadata_path = Self::get_worktree_metadata_path(git_repo_path)?; let worktree_metadata_folders = match fs::read_dir(&worktree_metadata_path) { Ok(read_dir) => read_dir .filter_map(|entry| entry.ok()) .collect::>(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => { return Err(WorktreeError::Repository(format!( "Failed to read worktree metadata directory at {}: {}", worktree_metadata_path.display(), e ))); } }; // read the worktrees/*/gitdir and see which one matches the worktree_path for entry in worktree_metadata_folders { let gitdir_path = entry.path().join("gitdir"); if gitdir_path.exists() && let Ok(gitdir_content) = fs::read_to_string(&gitdir_path) && normalize_macos_private_alias(Path::new(gitdir_content.trim())) .parent() .map(canonicalize_for_compare) .is_some_and(|p| p == worktree_root) { return Ok(Some(entry.file_name().to_string_lossy().to_string())); } } Ok(None) } fn get_worktree_metadata_path(git_repo_path: &Path) -> Result { let repo = Repository::open(git_repo_path).map_err(WorktreeError::Git)?; Ok(repo.commondir().join("worktrees")) } /// Comprehensive cleanup of worktree path and metadata to prevent "path exists" errors (blocking) fn comprehensive_worktree_cleanup( repo: &Repository, worktree_path: &Path, ) -> Result<(), WorktreeError> { let worktree_display_name = worktree_path.to_string_lossy().to_string(); debug!("Performing cleanup for worktree: {worktree_display_name}"); let git_repo_path = Self::get_git_repo_path(repo)?; // Step 1: Use GitService to remove the worktree registration (force) if present // The Git CLI is more robust than libgit2 for mutable worktree operations let git_service = GitService::new(); if let Err(e) = git_service.remove_worktree(&git_repo_path, worktree_path, true) { debug!("git worktree remove non-fatal error: {}", e); } // Step 2: Always force cleanup metadata directory (proactive cleanup) if let Err(e) = Self::force_cleanup_worktree_metadata(&git_repo_path, worktree_path) { debug!("Metadata cleanup failed (non-fatal): {}", e); } // Step 3: Clean up physical worktree directory if it exists if worktree_path.exists() { debug!( "Removing existing worktree directory: {}", worktree_path.display() ); std::fs::remove_dir_all(worktree_path).map_err(WorktreeError::Io)?; } // Step 4: Good-practice to clean up any other stale admin entries if let Err(e) = git_service.prune_worktrees(&git_repo_path) { debug!("git worktree prune non-fatal error: {}", e); } debug!("Comprehensive cleanup completed for worktree: {worktree_display_name}",); Ok(()) } /// Async version of comprehensive cleanup to avoid blocking the main runtime async fn comprehensive_worktree_cleanup_async( git_repo_path: &Path, worktree_path: &Path, ) -> Result<(), WorktreeError> { let git_repo_path_owned = git_repo_path.to_path_buf(); let worktree_path_owned = worktree_path.to_path_buf(); // First, try to open the repository to see if it exists let repo_result = tokio::task::spawn_blocking({ let git_repo_path = git_repo_path_owned.clone(); move || Repository::open(&git_repo_path) }) .await; match repo_result { Ok(Ok(repo)) => { // Repository exists, perform comprehensive cleanup tokio::task::spawn_blocking(move || { Self::comprehensive_worktree_cleanup(&repo, &worktree_path_owned) }) .await .map_err(|e| WorktreeError::TaskJoin(format!("Task join error: {e}")))? } Ok(Err(e)) => { // Repository doesn't exist (likely deleted project), fall back to simple cleanup debug!( "Failed to open repository at {:?}: {}. Falling back to simple cleanup for worktree at {}", git_repo_path_owned, e, worktree_path_owned.display() ); Self::simple_worktree_cleanup(&worktree_path_owned).await?; Ok(()) } Err(e) => Err(WorktreeError::TaskJoin(format!("{e}"))), } } /// Create worktree with retry logic in non-blocking manner async fn create_worktree_with_retry( git_repo_path: &Path, branch_name: &str, worktree_path: &Path, path_str: &str, ) -> Result<(), WorktreeError> { let git_repo_path = git_repo_path.to_path_buf(); let branch_name = branch_name.to_string(); let worktree_path = worktree_path.to_path_buf(); let path_str = path_str.to_string(); tokio::task::spawn_blocking(move || -> Result<(), WorktreeError> { // Prefer git CLI for worktree add to inherit sparse-checkout semantics let git_service = GitService::new(); match git_service.add_worktree(&git_repo_path, &worktree_path, &branch_name, false) { Ok(()) => { if !worktree_path.exists() { return Err(WorktreeError::Repository(format!( "Worktree creation reported success but path {path_str} does not exist" ))); } info!( "Successfully created worktree {} at {} (git CLI)", branch_name, path_str ); Ok(()) } Err(e) => { tracing::warn!( "git worktree add failed; attempting metadata cleanup and retry: {}", e ); // Force cleanup metadata and try one more time Self::force_cleanup_worktree_metadata(&git_repo_path, &worktree_path)?; // Clean up physical directory if it exists // Needed if previous attempt failed after directory creation if worktree_path.exists() { std::fs::remove_dir_all(&worktree_path).map_err(WorktreeError::Io)?; } if let Err(e2) = git_service.add_worktree( &git_repo_path, &worktree_path, &branch_name, false, ) { return Err(WorktreeError::GitService(e2)); } if !worktree_path.exists() { return Err(WorktreeError::Repository(format!( "Worktree creation reported success but path {path_str} does not exist" ))); } info!( "Successfully created worktree {} at {} after metadata cleanup (git CLI)", branch_name, path_str ); Ok(()) } } }) .await .map_err(|e| WorktreeError::TaskJoin(format!("{e}")))? } /// Get the git repository path fn get_git_repo_path(repo: &Repository) -> Result { repo.workdir() .ok_or_else(|| { WorktreeError::Repository("Repository has no working directory".to_string()) })? .to_str() .ok_or_else(|| { WorktreeError::InvalidPath("Repository path is not valid UTF-8".to_string()) }) .map(PathBuf::from) } /// Force cleanup worktree metadata directory fn force_cleanup_worktree_metadata( git_repo_path: &Path, worktree_path: &Path, ) -> Result<(), WorktreeError> { if let Some(worktree_name) = Self::find_worktree_git_internal_name(git_repo_path, worktree_path)? { let git_worktree_metadata_path = Self::get_worktree_metadata_path(git_repo_path)?.join(worktree_name); if git_worktree_metadata_path.exists() { debug!( "Force removing git worktree metadata: {}", git_worktree_metadata_path.display() ); std::fs::remove_dir_all(&git_worktree_metadata_path)?; } } Ok(()) } /// Clean up multiple worktrees pub async fn batch_cleanup_worktrees(data: &[WorktreeCleanup]) -> Result<(), WorktreeError> { for cleanup_data in data { tracing::debug!("Cleaning up worktree: {:?}", cleanup_data.worktree_path); if let Err(e) = Self::cleanup_worktree(cleanup_data).await { tracing::error!("Failed to cleanup worktree: {}", e); } } Ok(()) } /// Clean up a worktree path and its git metadata (non-blocking) /// If git_repo_path is None, attempts to infer it from the worktree itself pub async fn cleanup_worktree(worktree: &WorktreeCleanup) -> Result<(), WorktreeError> { let path_str = worktree.worktree_path.to_string_lossy().to_string(); // Get the same lock to ensure we don't interfere with creation let lock = { let mut locks = WORKTREE_CREATION_LOCKS.lock().unwrap(); locks .entry(path_str.clone()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() }; let _guard = lock.lock().await; // Try to determine the git repo path if not provided let resolved_repo_path = if let Some(repo_path) = &worktree.git_repo_path { Some(repo_path.to_path_buf()) } else { Self::infer_git_repo_path(&worktree.worktree_path).await }; if let Some(repo_path) = resolved_repo_path { Self::comprehensive_worktree_cleanup_async(&repo_path, &worktree.worktree_path).await?; } else { // Can't determine repo path, just clean up the worktree directory debug!( "Cannot determine git repo path for worktree {}, performing simple cleanup", path_str ); Self::simple_worktree_cleanup(&worktree.worktree_path).await?; } Ok(()) } /// Try to infer the git repository path from a worktree async fn infer_git_repo_path(worktree_path: &Path) -> Option { // Try using git rev-parse --git-common-dir from within the worktree let worktree_path_owned = worktree_path.to_path_buf(); let git_path = resolve_executable_path("git").await?; use utils::command_ext::NoWindowExt; let output = tokio::process::Command::new(git_path) .args(["rev-parse", "--git-common-dir"]) .current_dir(&worktree_path_owned) .no_window() .output() .await .ok()?; if output.status.success() { let git_common_dir = String::from_utf8(output.stdout).ok()?.trim().to_string(); // git-common-dir gives us the path to the .git directory // We need the working directory (parent of .git) let git_dir_path = Path::new(&git_common_dir); if git_dir_path.file_name() == Some(std::ffi::OsStr::new(".git")) { git_dir_path.parent()?.to_str().map(PathBuf::from) } else { // In case of bare repo or unusual setup, use the git-common-dir as is Some(PathBuf::from(git_common_dir)) } } else { None } } /// Simple worktree cleanup when we can't determine the main repo async fn simple_worktree_cleanup(worktree_path: &Path) -> Result<(), WorktreeError> { let worktree_path_owned = worktree_path.to_path_buf(); tokio::task::spawn_blocking(move || -> Result<(), WorktreeError> { if worktree_path_owned.exists() { std::fs::remove_dir_all(&worktree_path_owned).map_err(WorktreeError::Io)?; info!( "Removed worktree directory: {}", worktree_path_owned.display() ); } Ok(()) }) .await .map_err(|e| WorktreeError::TaskJoin(format!("{e}")))? } /// Move a worktree to a new location pub async fn move_worktree( repo_path: &Path, old_path: &Path, new_path: &Path, ) -> Result<(), WorktreeError> { let repo_path = repo_path.to_path_buf(); let old_path = old_path.to_path_buf(); let new_path = new_path.to_path_buf(); tokio::task::spawn_blocking(move || { let git_service = GitService::new(); git_service .move_worktree(&repo_path, &old_path, &new_path) .map_err(WorktreeError::GitService) }) .await .map_err(|e| WorktreeError::TaskJoin(format!("{e}")))? } /// Get the base directory for vibe-kanban worktrees pub fn get_worktree_base_dir() -> std::path::PathBuf { if let Some(override_path) = WORKSPACE_DIR_OVERRIDE.get() { // Always use app-owned subdirectory within custom path for safety. // This ensures orphan cleanup never touches user's existing folders. return override_path.join(".vibe-kanban-workspaces"); } Self::get_default_worktree_base_dir() } /// Get the default base directory (ignoring any override) pub fn get_default_worktree_base_dir() -> std::path::PathBuf { utils::path::get_vibe_kanban_temp_dir().join("worktrees") } pub async fn cleanup_suspected_worktree(path: &Path) -> Result { let git_marker = path.join(".git"); if !git_marker.exists() || !git_marker.is_file() { return Ok(false); } debug!("Cleaning up suspected worktree at {}", path.display()); let cleanup = WorktreeCleanup::new(path.to_path_buf(), None); Self::cleanup_worktree(&cleanup).await?; Ok(true) } } #[tokio::test] async fn create_worktree_when_repo_path_is_a_worktree() { use tempfile::TempDir; let td = TempDir::new().unwrap(); let repo_path = td.path().join("repo"); let git_service = GitService::new(); git_service .initialize_repo_with_main_branch(&repo_path) .unwrap(); let base_worktree_path = td.path().join("wt-base"); WorktreeManager::create_worktree( &repo_path, "wt-base-branch", &base_worktree_path, "main", true, ) .await .unwrap(); assert!(base_worktree_path.join(".git").is_file()); let child_worktree_path = td.path().join("wt-child"); WorktreeManager::create_worktree( &base_worktree_path, "wt-child-branch", &child_worktree_path, "main", true, ) .await .unwrap(); assert!(child_worktree_path.join(".git").is_file()); // Regression: repo_path itself is a worktree (so `.git` is a file), but metadata lookup still works. WorktreeManager::ensure_worktree_exists( &base_worktree_path, "wt-child-branch", &child_worktree_path, ) .await .unwrap(); } ================================================ FILE: dev_assets_seed/config.json ================================================ { "theme": "light", "executor": { "type": "claude" }, "disclaimer_acknowledged": true, "onboarding_acknowledged": true, "sound_alerts": true, "sound_file": "abstract-sound4", "push_notifications": true, "editor": { "editor_type": "VS_CODE", "custom_command": null }, "github": { "token": "", "default_pr_base": "main" } } ================================================ FILE: docs/.mintignore ================================================ AGENTS.md CLAUDE.md ================================================ FILE: docs/AGENTS.md ================================================ # Mintlify technical writing rule You are an AI writing assistant specialised in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. ## Working relationship - You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so - ALWAYS ask for clarification rather than making assumptions - NEVER lie, guess, or make up information ## Project context - Format: MDX files with YAML frontmatter - Config: docs.json for navigation, theme, settings - Components: Mintlify components ## Core writing principles ### Language and style requirements - Use clear, direct language appropriate for technical audiences - Write in second person ("you") for instructions and procedures - Use active voice over passive voice - Employ present tense for current states, future tense for outcomes - Avoid jargon unless necessary and define terms when first used - Maintain consistent terminology throughout all documentation - Keep sentences concise whilst providing necessary context - Use parallel structure in lists, headings, and procedures - Use British English spelling and grammar ### Content organisation standards - Lead with the most important information (inverted pyramid structure) - Use progressive disclosure: basic concepts before advanced ones - Break complex procedures into numbered steps - Include prerequisites and context before instructions - Provide expected outcomes for each major step - Use descriptive, keyword-rich headings for navigation and SEO - Group related information logically with clear section breaks - Make content evergreen when possible - Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason - Check existing patterns for consistency ### User-centred approach - Focus on user goals and outcomes rather than system features - Anticipate common questions and address them proactively - Include troubleshooting for likely failure points - Write for scannability with clear headings, lists, and white space - Include verification steps to confirm success ### Frontmatter requirements for pages - title: Clear, descriptive page title - description: Concise summary for SEO/navigation ### Do not - Skip frontmatter on any MDX file - Use absolute URLs for internal links - Include untested code examples - Make assumptions - always ask for clarification ## Mintlify component reference ### docs.json - Refer to the [docs.json schema](https://mintlify.com/docs.json) when building the docs.json file and site navigation ### Callout components #### Note - Additional helpful information Supplementary information that supports the main content without interrupting flow #### Tip - Best practices and pro tips Expert advice, shortcuts, or best practices that enhance user success #### Warning - Important cautions Critical information about potential issues, breaking changes, or destructive actions #### Info - Neutral contextual information Background information, context, or neutral announcements #### Check - Success confirmations Positive confirmations, successful completions, or achievement indicators ### Code components #### Single code block Example of a single code block: ```javascript config.js const apiConfig = { baseURL: 'https://api.example.com', timeout: 5000, headers: { 'Authorisation': `Bearer ${process.env.API_TOKEN}` } }; ``` #### Code group with multiple languages Example of a code group: ```javascript Node.js const response = await fetch('/api/endpoint', { headers: { Authorisation: `Bearer ${apiKey}` } }); ``` ```python Python import requests response = requests.get('/api/endpoint', headers={'Authorisation': f'Bearer {api_key}'}) ``` ```curl cURL curl -X GET '/api/endpoint' \ -H 'Authorisation: Bearer YOUR_API_KEY' ``` #### Request/response examples Example of request/response documentation: ```bash cURL curl -X POST 'https://api.example.com/users' \ -H 'Content-Type: application/json' \ -d '{"name": "John Doe", "email": "john@example.com"}' ``` ```json Success { "id": "user_123", "name": "John Doe", "email": "john@example.com", "created_at": "2024-01-15T10:30:00Z" } ``` ### Structural components #### Steps for procedures Example of step-by-step instructions: Run `npm install` to install required packages. Verify installation by running `npm list`. Create a `.env` file with your API credentials. ```bash API_KEY=your_api_key_here ``` Never commit API keys to version control. #### Tabs for alternative content Example of tabbed content: ```bash brew install node npm install -g package-name ``` ```powershell choco install nodejs npm install -g package-name ``` ```bash sudo apt install nodejs npm npm install -g package-name ``` #### Accordions for collapsible content Example of accordion groups: - **Firewall blocking**: Ensure ports 80 and 443 are open - **Proxy configuration**: Set HTTP_PROXY environment variable - **DNS resolution**: Try using 8.8.8.8 as DNS server ```javascript const config = { performance: { cache: true, timeout: 30000 }, security: { encryption: 'AES-256' } }; ``` ### Cards and columns for emphasising information Example of cards and card groups: Complete walkthrough from installation to your first API call in under 10 minutes. Learn how to authenticate requests using API keys or JWT tokens. Understand rate limits and best practices for high-volume usage. ### API documentation components #### Parameter fields Example of parameter documentation: Unique identifier for the user. Must be a valid UUID v4 format. User's email address. Must be valid and unique within the system. Maximum number of results to return. Range: 1-100. Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` #### Response fields Example of response field documentation: Unique identifier assigned to the newly created user. ISO 8601 formatted timestamp of when the user was created. List of permission strings assigned to this user. #### Expandable nested fields Example of nested field documentation: Complete user object with all associated data. User profile information including personal details. User's first name as entered during registration. URL to user's profile picture. Returns null if no avatar is set. ### Media and advanced components #### Frames for images Wrap all images in frames: Main dashboard showing analytics overview Analytics dashboard with charts #### Videos Use the HTML video element for self-hosted video content: Embed YouTube videos using iframe elements: #### Tooltips Example of tooltip usage: API #### Updates Use updates for changelogs: ## New features - Added bulk user import functionality - Improved error messages with actionable suggestions ## Bug fixes - Fixed pagination issue with large datasets - Resolved authentication timeout problems ## Required page structure Every documentation page must begin with YAML frontmatter: ```yaml --- title: "Clear, specific, keyword-rich title" description: "Concise description explaining page purpose and value" --- ``` ## Content quality standards ### Code examples requirements - Always include complete, runnable examples that users can copy and execute - Show proper error handling and edge case management - Use realistic data instead of placeholder values - Include expected outputs and results for verification - Test all code examples thoroughly before publishing - Specify language and include filename when relevant - Add explanatory comments for complex logic - Never include real API keys or secrets in code examples ### Accessibility requirements - Include descriptive alt text for all images and diagrams - Use specific, actionable link text instead of "click here" - Ensure proper heading hierarchy starting with H2 - Provide keyboard navigation considerations - Use sufficient colour contrast in examples and visuals - Structure content for easy scanning with headers and lists ## Component selection logic - Use **Steps** for procedures and sequential instructions - Use **Tabs** for platform-specific content or alternative approaches - Use **CodeGroup** when showing the same concept in multiple programming languages - Use **Accordions** for progressive disclosure of information - Use **RequestExample/ResponseExample** specifically for API endpoint documentation - Use **ParamField** for API parameters, **ResponseField** for API responses - Use **Expandable** for nested object properties or hierarchical information ================================================ FILE: docs/README.md ================================================ # Mintlify Starter Kit **[Mintlify Quickstart Guide](https://starter.mintlify.com/quickstart)** ## Development Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command: ``` npm i -g mint ``` Run the following command at the root of your documentation, where your `docs.json` is located: ``` mint dev ``` View your local preview at `http://localhost:3000`. ## Publishing changes Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. ## Need help? ### Troubleshooting - If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. - If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. ### Resources - [Mintlify documentation](https://mintlify.com/docs) - [Mintlify community](https://mintlify.com/community) ================================================ FILE: docs/agents/amp.mdx ================================================ --- title: "Amp" description: "Set up Amp code completion agent" icon: https://www.vibekanban.com/images/logos/amp-logo.svg --- ```bash npx -y @sourcegraph/amp ``` Complete the authentication flow as prompted. For more details, see the [Amp Owner's Manual](https://ampcode.com/manual#install). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Amp when creating task attempts. ================================================ FILE: docs/agents/ccr.mdx ================================================ --- title: "CCR (Claude Code Router)" description: "Set up CCR to orchestrate multiple Claude Code models" icon: https://www.vibekanban.com/images/logos/claude.svg#" --- CCR (Claude Code Router) lets you route coding prompts across different LLM providers and models, and select specialised models for specific tasks like long context, background work, or image understanding. CCR is not affiliated with, endorsed by, or connected to Claude Code or Anthropic. It is a third-party tool. ## Installation CCR is available via `npx` – no separate installation required. ```bash npx -y @musistudio/claude-code-router ui ``` This launches the CCR local UI where you configure providers and models. ## Authentication Authenticate and configure CCR outside of Vibe Kanban. Follow the instructions on the CCR GitHub repo: - GitHub: https://github.com/musistudio/claude-code-router You'll add providers, set API keys, and register model names in the CCR UI or via CCR's JSON configuration (see the CCR repo for the schema and file location). ## Configure CCR (Providers and Models) Configure CCR either via the UI or JSON config. In the CCR UI (`npx -y @musistudio/claude-code-router ui`): 1) Add providers - Choose a provider (e.g., `openrouter`, `deepseek`, etc.). - Enter the required API key(s) and settings for that provider. 2) Add models - For each provider, register the model identifier (e.g., `moonshotai/kimi-k2-0905`, `deepseek-chat`). - CCR supports configuring different models for specific cases: - `default`: general coding - `background`: lightweight/background operations - `think`: models that support "thinking" modes - `longContext`: very long inputs/files - `webSearch`: models that support web/tool use - `image`: models with vision capabilities Note: not all models support web search, thinking, or images. Choose models accordingly in the CCR UI. ### Configure via JSON (optional) CCR can also be configured via its JSON configuration file. Refer to the CCR GitHub documentation for the exact schema, keys, and file location. Define providers (with API keys) and map the model cases (`default`, `background`, `think`, `longContext`, `webSearch`, `image`) to specific provider/model pairs. ### Example: OpenRouter provider configured in CCR UI OpenRouter configured in CCR UI ### Example: CCR model mapping (default/background/think/etc.) CCR models configuration example ## Configure Vibe Kanban Vibe Kanban does not ship a default configuration for CCR. Add configurations to the existing Claude Code agent: 1) Open the "Coding Agent Configurations" page. 2) Add a new configuration for the Claude Code agent (or edit an existing one). 3) Enable the `claude_code_router` checkbox. 4) Optionally set a model string to target a specific CCR provider/model. See the [Agent Profiles & Variants](/settings/agent-configurations) guide for managing agent configurations. Model string format: `,` Examples: ```text openrouter,moonshotai/kimi-k2-0905 deepseek,deepseek-chat ``` Tips: - Create multiple configurations if you want easy switching between different models. - Leave the model string empty if you want CCR to use its own routing based on your CCR UI configuration (e.g., its `default`/`longContext`/etc. mappings). ### Example: Claude Code agent configuration in Vibe Kanban Claude Code agent configuration in Vibe Kanban ## Using CCR in Vibe Kanban When creating a Task Attempt, select the coding agent and configuration: choose the Claude Code agent, then pick one of your CCR-enabled configurations. ## Troubleshooting - Authentication errors: verify your API keys/provider settings in CCR (via UI or JSON config). - Model not found: confirm the model identifier is correct for the chosen provider. - Missing features (webSearch/think/image): switch to a model that supports the capability and update your CCR mapping (via UI or JSON config). ================================================ FILE: docs/agents/claude-code.mdx ================================================ --- title: "Claude Code" description: "Set up Anthropic's Claude Code agent" icon: https://www.vibekanban.com/images/logos/claude.svg#" --- ```bash npx -y @anthropic-ai/claude-code ``` Complete the authentication flow as prompted. For more details, see the [Claude Code documentation](https://docs.claude.com/en/docs/claude-code/quickstart). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Claude Code when creating task attempts. ================================================ FILE: docs/agents/cursor-cli.mdx ================================================ --- title: "Cursor Agent CLI" description: "Set up Cursor's command-line agent" icon: https://www.vibekanban.com/images/logos/cursor-logo-light.png --- ```bash curl https://cursor.com/install -fsS | bash ``` Verify installation with `cursor-agent --version`. For more details, see the [official installation guide](https://docs.cursor.com/en/cli/installation). Sign in with `cursor-agent login` (opens a browser). You can also set the `CURSOR_API_KEY` environment variable. Full instructions: https://docs.cursor.com/en/cli/reference/authentication Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Cursor Agent CLI when creating task attempts. ================================================ FILE: docs/agents/droid.mdx ================================================ --- title: "Factory Droid" description: "Set up Factory AI's Droid coding agent" icon: https://www.vibekanban.com/images/logos/factory-ai-logo-light.png --- ```bash curl -fsSL https://app.factory.ai/cli | sh ``` ```powershell irm https://app.factory.ai/cli/windows | iex ``` For the latest installation instructions and platform-specific guidance, visit the [Factory AI CLI documentation](https://factory.ai/product/cli). To use Droid in Vibe Kanban, you must first authenticate: 1. Run `droid` to launch the interactive CLI 2. Use the `/login` command within Droid to authenticate Alternatively, generate an API key from [Factory Settings](https://app.factory.ai/settings/api-keys) and set it as an environment variable: ```bash export FACTORY_API_KEY=fk-... ``` For detailed authentication instructions, see the [Factory AI documentation](https://docs.factory.ai/factory-cli/getting-started/overview). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Factory Droid when creating task attempts. ## Configuration Options Droid supports several configuration options in Vibe Kanban: - **Autonomy Level**: Controls permission level for file and system operations - `normal`: Read-only mode – safe for reviewing planned changes without execution - `low`: Basic file creation/editing while blocking system changes - `medium`: Development operations with recoverable side effects - `high`: Production operations with security implications or major side effects - `skip-permissions-unsafe`: Bypasses all permission checks (use only in isolated environments) (default) - **Model**: Specify which model to use - **Reasoning Effort**: Control the reasoning depth - `off`: Minimal reasoning - `low`: Light reasoning - `medium`: Balanced reasoning - `high`: Deep reasoning for complex tasks These options can be configured when creating agent configurations in Vibe Kanban. ================================================ FILE: docs/agents/gemini-cli.mdx ================================================ --- title: "Gemini CLI" description: "Set up Google Gemini CLI" icon: https://www.vibekanban.com/images/logos/gemini-logo.svg --- ```bash npx -y @google/gemini-cli ``` Complete the authentication flow as prompted. For more details, see [geminicli.com](https://geminicli.com/). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Gemini CLI when creating task attempts. ================================================ FILE: docs/agents/github-copilot.mdx ================================================ --- title: "GitHub Copilot" description: "Set up GitHub Copilot CLI" icon: https://www.vibekanban.com/images/logos/github-copilot-logo.svg --- ```bash npx -y @github/copilot ``` When prompted, follow the on-screen instructions to authenticate using the `/login` command. For more details, see the [GitHub Copilot CLI documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select GitHub Copilot when creating task attempts. ================================================ FILE: docs/agents/openai-codex.mdx ================================================ --- title: "OpenAI Codex" description: "Set up OpenAI Codex integration" icon: https://www.vibekanban.com/images/logos/openai-logo.svg --- ```bash npx -y @openai/codex ``` Complete the authentication flow as prompted. Follow the authentication instructions from the [OpenAI help centre](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan) to use Codex with your ChatGPT plan. Alternatively, Codex can be used via the OpenAI API by setting the `OPENAI_API_KEY` environment variable. For more details, see the [OpenAI documentation](https://developers.openai.com/codex/pricing/#use-an-openai-api-key) Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select OpenAI Codex when creating task attempts. ## Custom Configuration Directory By default, Codex stores its configuration and session data in `~/.codex`. If you have configured a custom location using the `CODEX_HOME` environment variable, Vibe Kanban will automatically detect and use that location. ```bash # Example: Using a project-specific Codex configuration export CODEX_HOME=/path/to/custom/codex npx vibe-kanban ``` This is useful for: - Project-specific Codex profiles - Separating work and personal configurations - Custom automation setups ================================================ FILE: docs/agents/opencode.mdx ================================================ --- title: "OpenCode" description: "Set up SST's OpenCode" icon: https://www.vibekanban.com/images/logos/opencode-light.svg --- ```bash npx -y opencode-ai ``` Complete the authentication flow as prompted. For more details, see the [OpenCode GitHub page](https://github.com/sst/opencode). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select OpenCode when creating task attempts. ================================================ FILE: docs/agents/qwen-code.mdx ================================================ --- title: "Qwen Code" description: "Set up Qwen code-focused assistant" icon: https://www.vibekanban.com/images/logos/qwen-logo.png#" --- ```bash npx -y @qwen-code/qwen-code ``` Complete the authentication flow as prompted. For more details, see the [official Qwen documentation](https://github.com/QwenLM/qwen-code). Once authenticated, launch Vibe Kanban: ```bash npx vibe-kanban ``` You can now select Qwen Code when creating task attempts. ================================================ FILE: docs/browser-testing.mdx ================================================ --- title: "Browser Testing" description: "Preview your app in the built-in browser, inspect components, and debug with full devtools" --- The built-in preview browser lets you test your application, inspect components, and open full devtools — all without leaving the workspace. ## 1. Configure a dev server If no dev server script is configured, the preview panel shows a setup prompt. Click **Edit Dev Server Script** to open the script dialog. Preview panel showing prompt to set up a dev server script Enter the command that starts your dev server (e.g. `npm run dev`, `pnpm dev`, `yarn dev`) and click **Save and Test** to verify it works. Script editor dialog with dev server command and Save and Test button You can also configure dev server scripts from **Settings** → **Repos** → select your repository → **Dev Server Script**. See [Projects & Repositories](/settings/projects-repositories) for details. ## 2. Start the dev server Once you've saved a script, click **Start dev server** in the preview panel to launch it. Preview panel showing the Start dev server button Logs stream in real-time in the **Log** panel (highlighted in red) on the right side of the workspace. Look for the URL output (e.g. `http://localhost:3000`) — Vibe Kanban detects this automatically and loads it in the preview. Dev server log output streaming in real-time in the Log panel ## 3. The preview browser Once the dev server starts, your application loads in the built-in browser. Here's an overview of the preview toolbar and what each control does. Annotated preview browser showing the toolbar controls numbered 1 through 7 1. **Back / Forward** — navigate between pages 2. **Inspect** (crosshair icon) — enter click-to-component mode 3. **DevTools** (terminal icon) — toggle the built-in devtools panel 4. **URL bar** — shows the current page address; type a URL to navigate manually 5. **Page actions** — submit, copy the URL, open in a new tab, and refresh 6. **Device modes** — switch between Desktop, Mobile, and Responsive views 7. **Pause** — pause or resume the dev server ## 4. Switch device modes You can test your application across different viewport sizes without resizing your browser window. The device mode buttons in the toolbar let you switch instantly. | Mode | What it does | |------|--------------| | **Desktop** | Full-width browser view | | **Mobile** | Phone frame at 390×844, with device chrome | | **Responsive** | Drag the edges to set a custom width and height | **Mobile mode** wraps your application in a phone frame so you can see exactly how it looks on a mobile device. Mobile device mode showing the application inside a phone frame at 390 by 844 pixels **Responsive mode** lets you drag the edges of the preview to test any custom width and height. Responsive device mode with a custom viewport width ## 5. Inspect components The preview browser includes a built-in click-to-component feature. Click **Select element as context** in the toolbar to enter inspect mode, hover over any element to highlight it, then click to select. Inspect mode showing the full flow: activating inspect, hovering over a component, and the selected context appearing in the chat When you click a component, inspect mode exits and the component's details are sent to the chat as context — you can then ask your coding agent questions about that specific component. This works automatically with React, Vue, Svelte, Astro, and plain HTML — no packages to install. ## 6. Open DevTools Click the terminal icon in the toolbar to toggle the built-in devtools panel. The devtools open at the bottom of the preview and give you a full debugging console without leaving the workspace. Eruda devtools panel open at the bottom of the preview browser showing the Console tab The devtools give you access to: - **Console** — view logs, warnings, errors, and run JavaScript - **Elements** — inspect and modify the DOM tree - **Network** — monitor requests and responses - **Resources** — view cookies, localStorage, and sessionStorage - **Sources** — view the page source code - **Info** — check the page URL, user agent, viewport size, and device details DevTools are powered by [Eruda](https://github.com/liriliri/eruda), a mobile-friendly debugging console. They run inside the preview iframe, so they reflect exactly what your application sees. ## Troubleshooting - Check the log output for errors — the dev server may still be starting - Verify the correct URL is detected in the preview address bar - Try refreshing the preview with the refresh button - Check the logs for build errors - Try a hard refresh (`Cmd/Ctrl + Shift + R`) - Restart the dev server with the play/pause button - Verify your framework supports hot module replacement (HMR) Another process is using the same port. Find and stop it, or change the port in your project config. Run `lsof -i :3000` (replace 3000 with your port) to see what's using it. - Make sure the dev server is running a **development** build (production builds strip component metadata) - For React, ensure the app has loaded before entering inspect mode - The HTML fallback always works — if you see tag names instead of component names, the framework adapter couldn't detect the framework ## Next steps Review diffs and provide feedback to your coding agent Configure setup, cleanup, and dev server scripts per repository ================================================ FILE: docs/cloud/authentication.mdx ================================================ --- title: "Authentication" description: "Sign in with GitHub or Google and manage your sessions" --- Vibe Kanban Cloud uses OAuth authentication, allowing you to sign in with your existing GitHub or Google account instead of creating a new password. ## How Authentication Works **What is OAuth?** OAuth is a secure way to sign in to applications using accounts you already have. When you click "Sign in with GitHub", you're redirected to GitHub to confirm you want to allow Vibe Kanban access. GitHub then tells Vibe Kanban who you are, without Vibe Kanban ever seeing your GitHub password. ### The Sign-In Flow On the login page, click **Sign in with GitHub** or **Sign in with Google**. Sign in dialog with GitHub and Google options You'll be redirected to GitHub or Google. If you're not already logged in there, you'll need to log in first. Click **Authorize** (GitHub) or **Allow** (Google) to grant Vibe Kanban access. After authorising, you're automatically redirected back to Vibe Kanban and signed in. ### What Vibe Kanban Can Access When you authorise Vibe Kanban, it only requests minimal permissions: | Provider | Access Granted | |----------|----------------| | **GitHub** | Your public profile (name, email, avatar) | | **Google** | Your basic profile (name, email, avatar) | Vibe Kanban **cannot**: - Access your private repositories (unless you grant additional permissions later) - Post on your behalf - Change your account settings - See your password ## Signing In ### First-Time Sign In The first time you sign in: 1. Click a sign-in button (GitHub or Google) 2. Authorise the application on the provider's website 3. A personal organisation is automatically created for you ### Returning Sign In For subsequent sign-ins: 1. Click the same sign-in button you used before 2. If you're already logged into the provider, you'll be signed in automatically 3. You'll land on your organisation's dashboard **Tip:** If you're already signed into GitHub or Google in your browser, clicking the sign-in button will log you in almost instantly without any prompts. ## Signing Out To sign out: 1. Click your **profile icon** in the bottom of the left sidebar 2. Click **Sign out** User menu showing Sign out option Signing out only affects the current browser. If you're signed in on multiple devices, you'll remain signed in on those devices. ### Signing Out of All Devices Currently, there's no way to sign out of all devices at once. If you need to revoke all sessions (e.g., if you suspect unauthorised access): 1. Go to your OAuth provider's settings: - **GitHub:** [github.com/settings/applications](https://github.com/settings/applications) - **Google:** [myaccount.google.com/permissions](https://myaccount.google.com/permissions) 2. Find "Vibe Kanban Cloud" and revoke access 3. All sessions will be invalidated ## Multiple Accounts ### Using Different Providers You can sign in with either GitHub or Google - they're treated as separate accounts. If you sign in with GitHub, then later sign in with Google, you'll have two separate accounts. **Account linking is not currently supported.** If you want to use both GitHub and Google, pick one and stick with it to avoid having duplicate accounts. ### Switching Accounts To switch to a different account: 1. Sign out of your current account 2. Sign in with the different provider or account If you need to sign in with a different GitHub/Google account than the one your browser remembers: 1. Sign out of Vibe Kanban 2. Go to the provider's website (github.com or google.com) 3. Sign out there 4. Return to Vibe Kanban and sign in - you'll be prompted to log in to the provider ## Security Best Practices Your Vibe Kanban security depends on your GitHub/Google account security. Use a strong, unique password. Enable two-factor authentication on GitHub or Google for extra security. Always sign out when using a shared or public computer. Periodically check what apps have access to your GitHub/Google account and revoke any you don't recognise. ## Troubleshooting **Problem:** After clicking sign in, you see an error about invalid redirect URI. **Cause:** The callback URL in your OAuth app doesn't match. **Solution:** 1. Check your OAuth app settings 2. Ensure the callback URL is exactly: - GitHub: `https:///v1/oauth/github/callback` - Google: `https:///v1/oauth/google/callback` 3. No trailing slashes, exact capitalisation **Problem:** The provider shows "access denied" or similar. **Cause:** You clicked "Deny" instead of "Authorize", or your organisation has OAuth app restrictions. **Solution:** 1. Try again and click "Authorize" or "Allow" 2. If you're part of a GitHub organisation with app restrictions, ask your admin to approve Vibe Kanban **Problem:** You're signed in with the wrong GitHub/Google account. **Solution:** 1. Sign out of Vibe Kanban 2. Go to github.com or google.com and sign out there 3. Sign in to the correct account on the provider 4. Return to Vibe Kanban and sign in **Problem:** You keep getting signed out. **Possible causes:** - Server was restarted (invalidates all sessions) - JWT secret was changed - You've been inactive for more than 7 days **Solution:** Simply sign in again. If it keeps happening, check if the server is restarting frequently. **Problem:** You revoked Vibe Kanban's access on GitHub/Google and now can't sign in. **Solution:** Just sign in again - you'll be prompted to re-authorise the application. ## Related Documentation - [Getting Started](/cloud/getting-started) - Initial setup including OAuth app creation - [Team Members](/cloud/team-members) - Managing user access within your organisation ================================================ FILE: docs/cloud/customisation.mdx ================================================ --- title: "Customising Your Board" description: "Configure columns, colours, and display settings" --- Every team works differently. Customise your kanban board to match your workflow by adding columns, changing colours, renaming statuses, and controlling what's visible. ## Display Settings Access display settings to customise your board columns: 1. Click the **Display Settings** button () in the filter bar 2. The settings panel opens showing all your columns Display Settings panel showing visible columns with toggles ## Managing Columns ### Adding a New Column Click the **Display Settings** button () in the filter bar. Click the **+ Add column** button at the bottom of the list. Type a name for your new status (e.g., "In Review", "Blocked", "Testing"). Click **Save** to apply your changes. The new column appears on your board. New columns are assigned a random colour. You can change it after creation. ### Renaming a Column Click the **Display Settings** button () in the filter bar. Click on the name of the column you want to rename. Enter the new name and click outside the field or press Enter. Click **Save** to apply your changes. Renaming a column doesn't affect the issues in it. All issues keep their current status - only the display name changes. ### Changing Column Colours Each column has a colour that appears in the column header and on issue cards. Click the **Display Settings** button () in the filter bar. Click the coloured circle next to the column name. Choose from the preset colours. Click **Save** to apply the new colour. ### Reordering Columns Change the order columns appear on your board: Click the **Display Settings** button () in the filter bar. Click and hold the drag handle () next to a column. Drag the column up or down to its new position. Click **Save** to apply the new order. Arrange columns to match your workflow from left to right - typically starting with "To Do" on the left and ending with "Done" on the right. ### Hiding Columns Hide columns you don't use regularly to keep your board clean: Click the **Display Settings** button () in the filter bar. Click the toggle switch next to the column you want to hide. When the toggle is off, the column is hidden. Click **Save** to hide the column. **Hidden columns:** - Don't appear on the board - Still exist - issues in them are preserved - Can be viewed using the "All" status tab - Can be unhidden at any time Hiding a column is useful for "Done" statuses. You can hide completed work to focus on active issues, but still access them when needed via the status tabs. ### Deleting Columns Remove columns you no longer need: Click the **Display Settings** button () in the filter bar. Click the **×** button next to the column you want to delete. Click **Save** to remove the column. **You cannot delete a column that contains issues.** Move all issues to another column first, then delete the empty column. ## Managing Tags Tags help categorise and filter issues. Tags are created inline when you add them to issues. ### Creating Tags To create a new tag: 1. Open any issue 2. Click the **Tags** field (or the **+** button next to existing tags) 3. Type the name of your new tag 4. If the tag doesn't exist, you'll see a **Create** option 5. Select a colour for the tag 6. Click to create and apply the tag Tags field showing a bug tag with + button to add more tags Tags are shared across all issues in the project. Once created, a tag can be added to any issue. ### Using Tags - Click the **Tags** field on any issue to add or remove tags - Use the tag filter in the filter bar to find issues with specific tags - Tags appear as coloured labels on issue cards **To remove a tag from an issue**, click the tag - it will show a strikethrough indicating it will be removed. Tag with strikethrough indicating removal ## Best Practices Start with fewer columns. You can always add more later. Too many columns creates confusion about where issues should go. Column names should be obvious to everyone on the team. "In Progress" is clearer than "WIP" or "Active". If you're not sure about a column, hide it instead of deleting. You can unhide it if you need it later. Use consistent colour meanings across projects. For example, always use red for blocked/urgent statuses. ## Troubleshooting The column probably has issues in it. You must move all issues to another column before deleting. Check: 1. Open Display Settings - it shows the issue count next to each status 2. Use the "All" status tab to see all issues including hidden ones Make sure you clicked **Save** after making changes in Display Settings. Changes aren't applied until you save. Open Display Settings and drag columns to reorder them. Remember to click **Save** after reordering. It might be hidden. Open Display Settings and check the visibility toggle for each column. Or use the "All" status tab to see issues in hidden columns. ## Related Documentation - [Kanban Board](/cloud/kanban-board) - Using the board interface - [Issues](/cloud/issues) - Creating and managing issues - [Filtering & Sorting](/cloud/filtering) - Finding issues with tags and filters ================================================ FILE: docs/cloud/filtering.mdx ================================================ --- title: "Filtering & Sorting" description: "Find issues quickly with search, filters, and sorting" --- As your project grows, you'll need ways to find specific issues quickly. The filter bar provides powerful search, filtering, and sorting capabilities to help you focus on what matters. ## The Filter Bar The filter bar sits above your kanban board and contains: - **Search field** - Find issues by text - **Filter dropdowns** - Filter by priority, assignee, tags - **Sort dropdown** - Change how issues are ordered - **Clear All button** - Reset all filters ## Searching Type in the search field to find issues by title. ### How Search Works - Search matches against issue **titles only** - Search is **case-insensitive** ("Login" matches "login") - Search finds **partial matches** ("log" matches "Login flow") - Results update as you type ### Tips for Effective Search - Search for keywords in titles - Use the Simple ID to find a specific issue (e.g., "TASK-123") - Combine search with filters for precise results If you can't find an issue, check that your filters aren't hiding it. Click **Clear All** to reset filters and search again. ## Filtering Filters let you show only issues that match certain criteria. ### Priority Filter Filter issues by priority level: Priority filter dropdown showing Urgent, High, Medium, and Low options | Priority | Colour | Description | |----------|--------|-------------| | **Urgent** | Red | Needs immediate attention | | **High** | Orange | Important, do soon | | **Medium** | Yellow | Normal priority | | **Low** | Grey | Can wait | **To filter by priority:** 1. Click the **Priority** dropdown 2. Select one or more priority levels 3. Only matching issues are shown ### Assignee Filter Filter issues by who's assigned: Assignee filter dropdown showing team members **Options:** - **Specific team members** - Show issues assigned to selected people - **Unassigned** - Show issues with no assignee **To filter by assignee:** 1. Click the **Assignee** dropdown 2. Select one or more team members, or "Unassigned" 3. Only matching issues are shown If you select multiple assignees, issues assigned to **any** of them will show (OR logic). An issue assigned to both Alice and Bob shows when filtering for either. ### Tags Filter Filter issues by tags: Tags filter dropdown showing available tags **To filter by tags:** 1. Click the **Tags** dropdown 2. Select one or more tags 3. Only issues with at least one selected tag are shown ### Combining Filters You can use multiple filters at once. When you do: - Filters within the same category use **OR** logic - Priority: Urgent OR High → shows both - Filters across categories use **AND** logic - Priority: Urgent AND Assignee: Alice → shows urgent issues assigned to Alice **Example:** To find high-priority bugs assigned to you: 1. Set Priority to "High" and "Urgent" 2. Set Assignee to yourself 3. Set Tags to "Bug" ## Sorting Control the order of issues within each column. Sort dropdown showing Manual, Priority, Created, Updated, and Title options ### Sort Options | Sort By | Description | |---------|-------------| | **Manual** | Your custom order (drag to reorder) | | **Priority** | Urgent first, then High, Medium, Low | | **Created** | Newest or oldest first | | **Updated** | Recently changed first | | **Title** | Alphabetical order | ### Sort Direction Click the sort direction button to toggle between: - **Ascending** (↑) - A→Z, oldest first, low→urgent - **Descending** (↓) - Z→A, newest first, urgent→low ### Manual Sort Mode When sort is set to **Manual**: - You can drag issues to reorder them within columns - Your custom order is saved and shared with the team - This is the default mode for most workflows If you switch away from Manual sort, you **cannot** drag to reorder issues. The order is determined by the selected sort field. ## Clearing Filters ### Clear All Click the **Clear All** button to reset all filters and search at once. This shows all issues in the project. ### Clear Individual Filters To clear a single filter: 1. Click the filter dropdown 2. Deselect all options To clear the search field, click the **×** button inside the search box. ## Filter Persistence Your filter and sort settings are saved for your session: - Filters persist when you navigate away and return - Filters reset when you switch projects - Filters reset when you sign out Filters are personal - they don't affect what other team members see. Everyone can have their own filter settings. ## Common Filter Scenarios 1. Click the **Assignee** dropdown 2. Select your name 3. Only your issues are shown across all columns 1. Click the **Priority** dropdown 2. Select "Urgent" 3. Optionally, also select "High" to include high-priority items 1. Click the **Tags** dropdown 2. Select the "Bug" tag (or your equivalent) 3. All bug issues are shown 1. Click the **Assignee** dropdown 2. Select "Unassigned" 3. Browse available issues and assign yourself to one 1. Click the **Sort** dropdown 2. Select "Updated" 3. Set direction to descending (newest first) 4. Recently changed issues appear at the top of each column ## Tips for Efficient Filtering If you know the issue ID or a word in the title, search is the fastest way to find it. During team standups, each person can filter to their assignments to give quick updates. Use multiple filters together to narrow down exactly what you're looking for. If you can't find something, click Clear All. You might have forgotten about an active filter. ## Related Documentation - [Kanban Board](/cloud/kanban-board) - Using the board interface - [Issues](/cloud/issues) - Understanding issue properties - [Customising Your Board](/cloud/customisation) - Setting up tags and statuses ================================================ FILE: docs/cloud/getting-started.mdx ================================================ --- title: "Getting Started with Cloud" description: "Sign in and start collaborating with your team" sidebarTitle: "Getting Started" --- Vibe Kanban Cloud lets you collaborate with your team in real-time. Your data syncs to the cloud, so everyone sees the same projects, issues, and updates. **Already using Vibe Kanban locally?** Cloud uses the same interface you're familiar with. The difference is your data syncs to the cloud and you can invite team members. ## Step 1: Launch Vibe Kanban If you haven't already, start Vibe Kanban: ```bash npx vibe-kanban ``` The application opens in your browser at `http://localhost:...`. ## Step 2: Sign In To access Cloud features, you need to sign in. Click the **Login** button in the sidebar or when prompted. Select how you want to sign in: - **Sign in with GitHub** - Use your GitHub account - **Sign in with Google** - Use your Google account Sign in dialog with GitHub and Google options You'll be redirected to GitHub or Google. Sign in if needed, then click **Authorize** or **Allow** to grant Vibe Kanban access. Vibe Kanban only requests access to your basic profile (name, email, avatar). It cannot access your private repositories or post on your behalf. After authorising, you're automatically redirected back and signed in. ## Step 3: Your Personal Organisation When you sign in for the first time, a **personal organisation** is automatically created for you, along with an **Initial Project** to get you started. This is your private workspace. Personal organisations are just for you - you cannot invite other members. To collaborate with a team, create a new organisation (see Step 5). ## Step 4: Create Your First Project Now create a project to organise your work. Click the **+ New Project** button in the sidebar. - **Name** - What you're working on (e.g., "Mobile App", "Website Redesign") - **Colour** - Pick a colour to identify the project Create Project dialog with name and colour fields Your project is created with a kanban board ready to use. Project kanban board with default columns ## Step 5: Create a Team Organisation (Optional) To collaborate with others, create a new organisation and invite team members. Click your **profile icon** in the bottom of the left sidebar. Click **+ Create organization** and enter your team or company name. Create Organization dialog Open Organisation Settings (click the gear icon next to your org name), then: - Click **Invite Member** - Enter your teammate's email address - Select their role (**Member** or **Admin**) - Click **Send Invitation** Invite Member dialog They'll receive an email with a link to join your organisation. ## You're Ready! You now have: - ✅ A Cloud account - ✅ An organisation for your team - ✅ A project with a kanban board **Next steps:** - [Create issues](/cloud/issues) to track your work - [Learn the kanban board](/cloud/kanban-board) to manage tasks - [Customise your board](/cloud/customisation) with columns and tags - [Invite more team members](/cloud/team-members) to collaborate ## Signing Out To sign out: 1. Click your **profile icon** in the bottom of the left sidebar 2. Click **Sign out** User menu showing Sign out option Your data remains safe in the cloud. Sign in again anytime to continue where you left off. ## Switching Between Local and Cloud You can use both local projects and Cloud projects: | Local Projects | Cloud Projects | |----------------|----------------| | Data stored on your machine | Data synced to cloud | | Only you can access | Team members can collaborate | | No sign-in required | Requires sign-in | | Works offline | Requires internet connection | Both appear in the same Vibe Kanban interface. Local projects show in one section, Cloud projects in another. ## Troubleshooting **Possible causes:** - Pop-up blocker preventing the OAuth window - Network connectivity issues **Solutions:** 1. Disable pop-up blocker for localhost 2. Check your internet connection 3. Try a different browser **Possible causes:** - You're not signed in **Solutions:** 1. Click **Login** in the sidebar 2. Complete the sign-in process **Possible causes:** - They haven't accepted the invitation - They signed in with a different email **Solutions:** 1. Check if the invitation is still pending in your Members settings 2. Resend the invitation 3. Verify they're using the email address you invited ## Related Documentation - [Authentication](/cloud/authentication) - More about signing in and sessions - [Organisations](/cloud/organizations) - Managing your organisation - [Team Members](/cloud/team-members) - Inviting and managing collaborators ================================================ FILE: docs/cloud/index.mdx ================================================ --- title: "Vibe Kanban Cloud" description: "Collaborate with your team in real-time with Cloud features" sidebarTitle: "Overview" --- ## What is Vibe Kanban Cloud? **Vibe Kanban Cloud** adds team collaboration to Vibe Kanban. Your projects, issues, and progress sync to the cloud so your entire team stays in sync. ### Key Benefits Invite team members and work together on the same projects. Everyone sees updates in real-time. Your data syncs to the cloud. Access your projects from any computer by signing in. Changes appear instantly for all team members. No need to refresh or manually sync. Cloud uses the same Vibe Kanban interface you already know. No new tools to learn. ### Cloud vs Local | Feature | Local | Cloud | |---------|-------|-------| | **Data storage** | On your computer | Synced to cloud | | **Team access** | Only you | Invite team members | | **Sign-in required** | No | Yes (GitHub or Google) | | **Real-time collaboration** | No | Yes | | **Works offline** | Yes | Requires internet | You can use both Local and Cloud projects in the same Vibe Kanban installation. They appear in separate sections of the sidebar. ## How It Works 1. **Run Vibe Kanban** as usual with `npx vibe-kanban` 2. **Sign in** with your GitHub or Google account (a personal organisation is created automatically) 3. **Create projects** in your personal workspace 4. **Create a team organisation** if you want to collaborate (optional) 5. **Invite team members** via email and collaborate in real-time Your local projects remain untouched. Cloud projects are stored separately and sync automatically. ## Key Concepts ### Organisations An **organisation** is your team's shared space. It contains: - Team members with different roles (Admin, Member) - Projects that everyone can access - Shared settings Think of it as your company or team in Vibe Kanban. ### Projects A **project** is a kanban board for a specific initiative. Each project has: - Customisable status columns - Issues to track work - Tags for categorisation ### Issues An **issue** is a single piece of work. Unlike local tasks, Cloud issues have: - Simple IDs (like `TASK-123`) for easy reference - Multiple assignees - Priority levels - Comments for discussion - Sub-issues for breaking down work ## Features ### Team Collaboration - Invite members via email - Role-based permissions (Admin and Member) - See who's assigned to what ### Real-Time Sync - Changes appear instantly for everyone - No conflicts or manual merging - Always up to date ### Advanced Kanban - Drag-and-drop issues between columns - Custom status columns with colours - Filter by priority, assignee, or tags - Multiple sort options ### GitHub Integration - Link pull requests to issues - Review code directly in Vibe Kanban - Track PR status ## Getting Started ```bash npx vibe-kanban ``` Click **Login** and sign in with GitHub or Google. A personal organisation is created automatically. Click **+ New Project** and start adding issues. To collaborate, create a new organisation from the user menu and invite members by email. Step-by-step instructions for setting up Cloud ## Learn More Create and manage issues to track your work Use the board to visualise and manage progress Invite colleagues and manage permissions Configure columns, colours, and tags ================================================ FILE: docs/cloud/issues.mdx ================================================ --- title: "Issues" description: "Create, edit, and manage issues to track your work" --- Issues are the individual work items in your project - bugs to fix, features to build, tasks to complete. Each issue lives on your kanban board and can be assigned to team members, tagged, prioritised, and tracked through your workflow. Kanban board with New Issue panel showing issue creation form ## What is an Issue? An **issue** represents a single piece of work. It has: - **Title** - A short description of what needs to be done - **Description** - Detailed information, requirements, or context - **Status** - Which column it's in (To Do, In Progress, Done, etc.) - **Priority** - How urgent it is (Urgent, High, Medium, Low) - **Assignees** - Who's responsible for the work - **Tags** - Labels for categorisation and filtering - **Simple ID** - A unique identifier like `TASK-123` for easy reference ## Creating Issues ### From a Column To create an issue in a specific column: 1. Click the **+** button at the top of any status column 2. The New Issue panel opens with that status pre-selected 3. Fill in the issue details and click **Create Task** Column header showing the + button to create a new issue ### From the Header To create an issue using the header button: Header showing the + button next to status tabs for creating a new issue Click the **+** button in the header next to the status tabs. Type a clear, descriptive title. The title field is focused automatically. Choose which column the issue should start in. Select a priority level (default is no priority): | Priority | When to Use | |----------|-------------| | **Urgent** | Needs immediate attention, blocking other work | | **High** | Important, should be done soon | | **Medium** | Normal priority | | **Low** | Nice to have, can wait | Click the assignee field and select one or more team members. You can assign multiple people to the same issue. Select tags to categorise the issue. Tags help with filtering and organisation. Write a detailed description using the rich text editor. You can format text, add lists, code blocks, and links. Click **Create Task** to save the issue. The issue will appear on the board in the selected status column. ### Create with Workspace If you want to immediately start working on an issue with AI assistance: New Issue panel showing Create draft workspace immediately toggle 1. When creating an issue, enable **Create draft workspace immediately** 2. The issue is created with a linked [workspace](/workspaces/index) 3. You can start working with coding agents right away See [Workspaces](/workspaces/index) to learn more about working with coding agents. ## Editing Issues ### Opening an Issue Click any issue card on the board to open the issue panel. The panel slides in from the right side. ### Auto-Save Changes to the title and description **save automatically** as you type. You don't need to click a save button - just edit and your changes are saved. Auto-save has a small delay (about half a second) to avoid saving every keystroke. If you close the panel immediately after typing, wait a moment for the save to complete. ### Editing Properties Properties like status, priority, assignees, and tags save **immediately** when you change them: | Property | How to Edit | |----------|-------------| | **Status** | Click the status dropdown and select a new status, or drag the card to another column | | **Priority** | Click the priority dropdown and select a level | | **Assignees** | Click the assignees field, select/deselect team members | | **Tags** | Click the tags field, select/deselect tags | ### Editing the Description The description uses a rich text editor with formatting options: - **Bold**, *italic*, ~~strikethrough~~ - Bullet lists and numbered lists - Code blocks for technical content - Links to external resources ## Issue Sections The issue panel has collapsible sections for additional information: ### Workspaces Section Shows development workspaces linked to this issue. [Workspaces](/workspaces/index) are where coding agents do their work. Workspaces section showing a linked active workspace Each linked workspace shows: - **Status** - Active, idle, or completed - **Age** - How long ago it was created - **PR status** - Whether a pull request has been created **To link a workspace:** Click the **+** button in the Workspaces section header to create a new workspace or link an existing one. Link workspace dropdown showing Create new workspace and existing workspaces **To unlink a workspace:** Click the **⋯** menu on the workspace and select **Unlink from issue**. Workspace menu showing Unlink from issue and Delete workspace options ### Sub-Issues Section Issues can have child issues (subtasks) for breaking down work: Sub-issues section showing No sub-issues message with add button - **Add Sub-Issue** - Create a new child issue - **Link Existing Issue** - Make an existing issue a subtask - **View Parent** - If this is a sub-issue, see its parent ### Comments Section Discussion thread for the issue: - **Add Comment** - Share updates, ask questions, or provide feedback - **React** - Add emoji reactions to comments - **Edit/Delete** - Modify or remove your own comments ## Sub-Issues (Subtasks) Break large issues into smaller, manageable pieces using sub-issues. ### Creating a Sub-Issue Click the issue that will be the parent. Click the **Sub-Issues** section header to expand it. Click the **+** button in the Sub-Issues section header. A dropdown appears with options to create a new issue or link an existing one. Add Sub-issue dropdown showing Create new issue and existing issues to link - Click **Create new issue** to create a new sub-issue - Or select an existing issue from the list to link it as a sub-issue ### Viewing the Parent Issue When viewing a sub-issue, you'll see a **Parent** link below the issue properties. Click it to navigate to the parent issue. Sub-issue showing Parent: ISS-1 link to navigate to parent issue ### Sub-Issue Behaviour - Sub-issues appear on the board just like regular issues - They can have their own status, priority, and assignees - Completing all sub-issues doesn't automatically complete the parent - Sub-issues can't have their own sub-issues (only one level deep) ## Issue Actions Issue Actions provide quick access to common operations on an issue. You can access them in two ways: ### From the Issue Panel Click the **More** button (three dots) in the top-right corner of the issue panel. Issue panel header showing the three dots More button ### From the Command Bar Open the command bar with `Cmd/Ctrl + K`, then select **Issue Actions**. Command panel showing Issue Actions option ### Available Actions Issue Actions menu showing all available actions with keyboard shortcuts | Action | Shortcut | Description | |--------|----------|-------------| | **Create Issue** | `I C` | Create a new issue | | **Change Status** | `I S` | Move issue to a different status | | **Change Priority** | `I P` | Set or change priority level | | **Change Assignees** | `I A` | Add or remove assignees | | **Make Sub-issue of** | `I M` | Make this issue a child of another | | **Add Sub-issue** | `I B` | Add a sub-issue to this issue | | **Link Workspace** | `I W` | Connect a workspace to this issue | | **Duplicate Issue** | `I D` | Create a copy of this issue | | **Delete Issue** | - | Permanently delete this issue | ## Copying Issue Links To share an issue with someone: 1. Open the issue panel 2. Click the **Copy Link** button () in the panel header 3. The issue URL is copied to your clipboard Share this link with team members - they can click it to go directly to that issue. ## Selecting Multiple Issues You can select multiple issues to perform bulk operations, similar to how you'd select files in a file manager. ### Selection Methods | Method | Action | |--------|--------| | **Checkbox** | Hover over the left edge of a list row to reveal its checkbox, then click | | **Cmd/Ctrl + Click** | Toggle an individual issue in or out of the selection | | **Shift + Click** | Select a range of issues between the last selected issue and the clicked issue | | **X** | Toggle the currently open issue in or out of the selection | | **Shift + J / Shift + ↓** | Extend the selection downward by one issue | | **Shift + K / Shift + ↑** | Extend the selection upward by one issue | | **Cmd/Ctrl + A** | Select all visible issues | | **Escape** | Clear the selection | Selection works in both the [kanban board](/cloud/kanban-board) and [list view](/cloud/list-view). In list view, checkboxes appear on hover for each row. In the kanban board, use modifier keys or keyboard shortcuts to select cards. ### Bulk Action Bar When two or more issues are selected, a floating action bar appears at the bottom of the screen. From this bar you can: | Action | Description | |--------|-------------| | **Status** | Change the status of all selected issues at once | | **Priority** | Set the same priority across all selected issues | | **Assignee** | Assign or change assignees for all selected issues | | **Delete** | Permanently delete all selected issues | Click the **X** button on the action bar or press **Escape** to clear the selection. Bulk delete is permanent and cannot be undone. All selected issues, their comments, and their history will be removed. Use filters to narrow down your view before selecting issues. For example, filter by a specific status, then press **Cmd/Ctrl + A** to select all matching issues for a bulk status change. ## Deleting Issues To delete a single issue: 1. Open the issue panel 2. Click the **More** button (three dots) to open Issue Actions 3. Select **Delete Issue** 4. Confirm the deletion To delete multiple issues, select them and click **Delete** in the bulk action bar. Deleting an issue is permanent. The issue, its comments, and its history are removed. Sub-issues are not deleted - they become standalone issues. ## Issue Simple IDs Every issue has a **Simple ID** - a short, unique identifier like `TASK-123`. This makes it easy to reference issues in conversations, commits, and documentation. The Simple ID is shown: - On issue cards on the board - In the issue panel header - In the URL when viewing an issue Use Simple IDs in commit messages (e.g., "Fix login bug TASK-123") to create a clear connection between code and issues. ## Best Practices Issue titles should describe what needs to be done, not the problem. "Add password reset flow" is better than "Users can't reset password". Reserve "Urgent" for genuine emergencies. If everything is urgent, nothing is. Assign issues to people who will actually work on them. Unassigned issues are fine for backlog items. As requirements change or you learn more, update the description. It's the source of truth for what needs to be done. ## Related Documentation - [Kanban Board](/cloud/kanban-board) - Moving and organising issues on the board - [Filtering & Sorting](/cloud/filtering) - Finding issues quickly - [Customising Your Board](/cloud/customisation) - Configuring statuses and tags - [Workspaces](/workspaces/index) - Working with coding agents ================================================ FILE: docs/cloud/kanban-board.mdx ================================================ --- title: "Kanban View" description: "Visualise and manage your work with the kanban board" --- The kanban board is your primary view for managing work. Issues are organised into columns representing different stages of your workflow, and you can drag and drop them as work progresses. Kanban board showing columns with issue cards ## Board Layout The kanban board consists of: - **Filter Bar** - Search, filter, and sort your issues (top) - **Status Columns** - Vertical columns representing workflow stages - **Issue Cards** - Individual work items within columns - **Column Headers** - Show status name with colour indicator ### Status Columns Each column represents a status in your workflow. By default, projects have six statuses: | Column | Purpose | |--------|---------| | **To do** | Ready to start | | **In progress** | Currently being worked on | | **In review** | Waiting for code review | | **Done** | Completed | Two additional statuses are hidden by default: **Backlog** (for ideas and future work) and **Cancelled** (for issues that won't be done). You can access them via the status tabs above the board. You can [customise these columns](/cloud/customisation) - add new ones, rename them, change colours, or hide/show ones you need. ### Issue Cards Each card shows key information at a glance: - **Simple ID** - The issue identifier (e.g., ISS-1) - **Title** - What needs to be done - **Priority** - Colour-coded indicator (if set) - **Assignees** - Avatar(s) of assigned team members - **Tags** - Coloured labels (if any) Click any card to open the full [issue panel](/cloud/issues). ## Drag and Drop Move issues between columns and reorder them within columns using drag and drop. Dragging an issue card between columns ### Moving Between Columns To change an issue's status: 1. Click and hold an issue card 2. Drag it to the target column 3. Release to drop it The issue's status updates immediately, and all team members see the change in real-time. ### Reordering Within a Column To change the order of issues within a column: 1. Make sure you're in **Manual** sort mode (check the sort dropdown in the filter bar) 2. Click and hold an issue card 3. Drag it up or down within the same column 4. Release to drop it in the new position Reordering only works when sort mode is set to **Manual**. If you're sorting by Priority, Created, or another field, the order is determined automatically and you can't drag to reorder. ### Drag Indicators While dragging, you'll see visual feedback: - **Cursor changes** - Shows you're in drag mode - **Drop target** - Highlights where the card will land - **Card shadow** - Shows the card being moved ## Status Tabs Above the board, tabs let you switch between views: Status tabs showing Active, All, Backlog, and Cancelled | Tab | View | Shows | |-----|------|-------| | **Active** | Kanban | Kanban columns for visible statuses | | **All** | [List View](/cloud/list-view) | All issues including hidden statuses | | **Backlog**, **Cancelled**, etc. | [List View](/cloud/list-view) | Issues in that specific hidden status | Hidden status tabs (like Backlog or Cancelled) only appear if you've hidden those statuses in [Display Settings](/cloud/customisation). This is useful for keeping your board clean while still having quick access to archived or backlogged items. ## Multi-Select Select multiple issue cards to perform bulk actions directly from the board. ### How to Select - **Cmd/Ctrl + Click** a card to toggle it in or out of the selection - **Shift + Click** a card to select a range from the last selected card - **X** to toggle the currently open issue - **Shift + J / ↓** or **Shift + K / ↑** to extend the selection by one issue - **Cmd/Ctrl + A** to select all visible issues - **Escape** to clear the selection Selected cards are highlighted with an accent ring. While issues are selected, drag and drop is disabled to prevent accidental moves. When two or more issues are selected, a **bulk action bar** appears at the bottom of the screen. You can change the status, priority, or assignees of all selected issues at once, or delete them. See [Selecting Multiple Issues](/cloud/issues#selecting-multiple-issues) for full details. ## Working with the Board ### Opening an Issue Click any issue card to open the issue panel on the right side of the screen. The panel shows full details and lets you edit the issue. ### Quick Status Change Instead of opening an issue, you can change its status by dragging it to another column. This is the fastest way to update many issues. ### Keyboard Shortcuts | Shortcut | Action | |----------|--------| | `X` | Toggle current issue selection | | `Shift + J / ↓` | Extend selection down | | `Shift + K / ↑` | Extend selection up | | `Cmd/Ctrl + A` | Select all issues | | `Escape` | Clear selection / close the issue panel | | `Cmd/Ctrl + K` | Open the command bar for quick actions | ## Real-Time Collaboration The board updates in real-time as team members make changes: - **Issue moved** - Cards animate to their new position - **Issue created** - New cards appear automatically - **Issue updated** - Changes to titles, priorities, etc. appear instantly - **Issue deleted** - Cards disappear from the board You don't need to refresh the page - everything syncs automatically. If you and a teammate move the same issue at the same time, the last action wins. The board will show the final position after both actions complete. ## Empty Columns Columns with no issues show an empty state. You can: - Drag issues into empty columns - Use the **+** button to create a new issue in that column - [Hide the status](/cloud/customisation) in Display Settings if you don't need it on the board Empty column showing the + button to create a new issue ## Best Practices Don't have too many issues in "In Progress". It's better to finish work than to start new work. Consider adding WIP limits to your process. Use the board in daily standups to review what's in progress and identify blockers. The visual layout makes status updates quick. Each column should represent a real stage in your workflow. If issues skip a column, you probably don't need it. When the board gets busy, use filters to focus on what matters - your assigned issues, high priority items, or specific tags. ## Troubleshooting Make sure you're in **Manual** sort mode. Click the sort dropdown in the filter bar and select "Manual". If you're sorting by Priority, Created, or another field, the order is fixed. Check your filters - you might have active filters hiding some issues. Click **Clear All** in the filter bar to reset filters and see all issues. The column might be hidden. Go to [Display Settings](/cloud/customisation) and check if the status is set to hidden. Toggle it back to visible. Check your internet connection. The board requires an active connection to sync changes. If you're offline, changes won't be saved until you reconnect. ## Related Documentation - [List View](/cloud/list-view) - Alternative tabular view for issues - [Issues](/cloud/issues) - Creating and editing issues - [Filtering & Sorting](/cloud/filtering) - Finding issues quickly - [Customising Your Board](/cloud/customisation) - Configuring columns and display ================================================ FILE: docs/cloud/list-view.mdx ================================================ --- title: "List View" description: "View and manage all your issues in a tabular format" --- The list view provides a tabular way to see all your issues, including those in hidden statuses. It's ideal for reviewing many issues at once and getting a complete overview of your project. List view showing issues grouped by status ## Accessing List View To switch to list view, click the **All** tab in the header above the board. Status tabs showing Active, All, Backlog, and Cancelled | Tab | What it shows | |-----|---------------| | **All** | All issues across all statuses (including hidden) | | **Backlog**, **Cancelled**, etc. | Issues in that specific hidden status only | To return to the kanban view, click the **Active** tab. ## List Layout Issues in the list view are grouped by status. Each status section shows: - **Status name** with colour indicator - **Issue count** for that status - **Collapsible sections** - click to expand or collapse List view showing status sections with issues grouped by status ### Issue Row Details Each row in the list shows: - **Simple ID** - The issue identifier (e.g., ISS-1) - **Title** - The issue title - **Workspace indicator** - Shows if the issue has linked workspaces - **Age** - How long ago the issue was created (e.g., "12d" for 12 days) Click any row to open the full [issue panel](/cloud/issues). ## Multi-Select in List View List view supports multi-select for bulk operations. Each row has a checkbox that appears when you hover over its left edge. ### Selection Methods - **Checkbox** - Hover over the left edge of a row to reveal the checkbox, then click to select - **Cmd/Ctrl + Click** a row to toggle it in or out of the selection - **Shift + Click** a row to select a range from the last selected row - **X** to toggle the currently open issue - **Shift + J / ↓** or **Shift + K / ↑** to extend the selection by one row - **Cmd/Ctrl + A** to select all visible issues - **Escape** to clear the selection Selected rows are highlighted. When two or more issues are selected, the **bulk action bar** appears at the bottom of the screen with options to change status, priority, assignees, or delete. See [Selecting Multiple Issues](/cloud/issues#selecting-multiple-issues) for full details. ## When to Use List View List view is best for: See every issue in your project at once, including hidden statuses like Backlog and Cancelled. Combined with search and filters, quickly locate issues across all statuses. Select multiple issues with checkboxes to change status, priority, or assignees in bulk. View and manage issues in Backlog, Cancelled, or other hidden statuses. ## Filtering and Sorting The filter bar works the same in list view as in kanban view: - **Search** - Find issues by title - **Priority** - Filter by priority level - **Assignee** - Filter by team member - **Tags** - Filter by tags - **Sort** - Change the order of issues See [Filtering & Sorting](/cloud/filtering) for more details. ## Related Documentation - [Kanban View](/cloud/kanban-board) - The default board view with columns - [Issues](/cloud/issues) - Creating and editing issues - [Filtering & Sorting](/cloud/filtering) - Finding issues quickly ================================================ FILE: docs/cloud/migration.mdx ================================================ --- title: "Migration from Legacy Local Projects" description: "Upgrade your local projects to Cloud for team collaboration" --- If you've been using Vibe Kanban locally, you can migrate your existing projects to Cloud to unlock team collaboration, real-time sync, and access from any device. ## Why Migrate? Moving your projects to Cloud gives you: Access your projects from anywhere. Your data syncs automatically across devices. Invite teammates to your organisation and assign work to team members. Add comments to issues to keep discussions in context with the work. Break down complex work into smaller, manageable pieces. Link pull requests directly to issues for seamless code tracking. Organise work with custom tags and priority levels. ## Before You Start Before migrating, make sure you have: 1. **A Cloud account** - Sign in with GitHub or Google (a personal organisation is created automatically) 2. **Local projects** - Projects stored on your machine that you want to migrate Migration copies your data to Cloud - your local projects remain unchanged. You can continue using them locally if needed. ## Starting the Migration ### From the Project Banner When viewing a local project that hasn't been migrated, you'll see a banner prompting you to migrate: Migrate this project to the cloud banner with Learn more button Click **Learn more** to open the migration wizard. ## Migration Steps The migration wizard guides you through four steps: ### Step 1: Introduction The first screen explains what Cloud offers and what will happen during migration. Migration introduction screen showing Cloud benefits - If you're **not signed in**, click **Sign In** to authenticate with GitHub or Google - If you're **already signed in**, click **Continue** to proceed When you click Sign In, you'll see the authentication dialog: Sign in dialog with GitHub and Google options ### Step 2: Choose Projects Select which local projects you want to migrate: Choose projects screen showing project list and organisation selector You'll see a list of all your local projects with their names and IDs. Click the checkbox next to each project you want to migrate. Use **Select All** to choose everything at once. Project selected for migration with Migrate button Projects you've already migrated are shown separately with a **View** link to open them in Cloud. If you have multiple organisations, select which one should receive the migrated projects. Click **Continue** to proceed to the migration step. You can migrate projects in batches. If you have many projects, consider migrating a few first to verify everything works as expected. ### Step 3: Migrate This step performs the actual migration: 1. Click **Start Migration** to begin 2. Wait while your data is transferred to Cloud 3. You'll see a summary report when complete Migration summary showing projects and tasks migrated | Item | Description | |------|-------------| | **Projects** | How many projects were migrated vs. total | | **Tasks** | How many issues/tasks were migrated | | **PR Merges** | How many pull request links were migrated | | **Warnings** | Any issues encountered during migration | Don't close the browser or navigate away while migration is in progress. This could result in incomplete data transfer. Click **Continue** to proceed to the finish screen. ### Step 4: Finish The finish screen shows your migrated projects with quick access links: Finish screen showing migrated projects with View links - Click **View** next to any project to open it in Cloud - Click **Migrate More Projects** to migrate additional projects ## What Gets Migrated The migration transfers the following data: ### Projects - Project name - Project colour - All project settings ### Issues (Tasks) - Issue titles and descriptions - Status (which column they're in) - Any existing metadata ### Pull Request Links - Connections between issues and GitHub PRs - PR status information ## What Doesn't Get Migrated Some data is created fresh in Cloud: - **Team members** - You'll need to invite teammates after migration - **Comments** - Local projects don't have comments, so there's nothing to migrate - **Tags** - You'll set these up in your Cloud project - **Priorities** - Issues will need priorities assigned after migration ## After Migration When you return to your local project, you'll see a banner indicating it's been migrated to Cloud: Project migrated to Cloud banner with View project button Click **View project** to open the Cloud version with all collaboration features. Once your projects are in Cloud: ### Invite Your Team 1. Go to your [organisation settings](/cloud/organizations) 2. [Invite team members](/cloud/team-members) via email 3. They'll receive an invitation to join ### Set Up Your Board 1. Open a migrated project 2. [Add tags](/cloud/customisation) for categorisation 3. [Set priorities](/cloud/issues) on important issues 4. [Customise columns](/cloud/customisation) if needed ### Connect GitHub (Optional) If you want PR tracking: 1. Go to organisation settings 2. [Install the GitHub App](/integrations/github-integration) 3. Enable code review for your repositories ## Troubleshooting Check your internet connection and try again. If the problem persists: 1. Refresh the page 2. Sign out and sign back in 3. Try migrating fewer projects at once If you continue to have issues, check the error message for specific details. The migration report shows how many projects were skipped. Common reasons: - **Duplicate names** - A project with the same name already exists in the target organisation - **Invalid data** - The project has corrupted or incomplete data Try renaming the local project and migrating again. Check the following: 1. **Filters** - Make sure you don't have active filters hiding issues 2. **Columns** - Check if issues are in a hidden status column 3. **Migration report** - Review the report to see if tasks were skipped If tasks were skipped, there may have been data issues. Check the warnings in the migration report. You can run the migration wizard again and select a different target organisation. Note that this will create duplicate projects if you migrate the same projects twice. Migration copies data to Cloud - it doesn't delete your local projects. Your local data remains unchanged, so there's nothing to "undo". If you want to remove migrated projects from Cloud, you can delete them from the Cloud project list. ## Related Documentation - [Getting Started](/cloud/getting-started) - Set up your Cloud account - [Organisations](/cloud/organizations) - Managing your organisation - [Team Members](/cloud/team-members) - Inviting teammates - [Projects](/cloud/projects) - Working with Cloud projects ================================================ FILE: docs/cloud/organizations.mdx ================================================ --- title: "Organisations" description: "Create and manage organisations to group your team and projects" --- Organisations are the top-level container in Vibe Kanban Cloud. They group your team members and projects together. ## What is an Organisation? An **organisation** represents your team, company, or group. It contains: - **Projects** - Your kanban boards for managing tasks - **Members** - People who can access the organisation - **Settings** - Configuration that applies to all projects ### Organisation Structure An organisation acts as a container that holds two main things: **Members** are the people who belong to your organisation. Each member has a role: - **Admins** have full control - they can invite/remove members, change settings, and delete the organisation - **Members** can work on all projects but cannot manage organisation settings **Projects** are your kanban boards. Each project contains tasks, statuses, and tags. All members of an organisation can see and work on all projects within it. Think of an organisation like a company: the company has employees (members) and departments or initiatives (projects). Everyone in the company can see what each department is working on. ## Personal Organisation When you sign in for the first time, a **personal organisation** is automatically created for you, along with an **Initial Project**. This is your private workspace. Personal organisations: - Are created automatically on first sign-in - Come with an "Initial Project" ready to use - Cannot have additional members invited - Cannot be deleted - Are only accessible by you To collaborate with others, you need to create a new (non-personal) organisation. ## Creating a Team Organisation To collaborate with team members, create a new organisation: Click your **profile icon** in the bottom of the left sidebar. Select **+ Create organization** from the menu. Fill in the organisation details: Create New Organization dialog - **Organization Name** - Your team or company name (e.g., "Acme Corporation") - **Slug** - URL-friendly identifier, auto-generated from the name (lowercase, numbers, hyphens only) Click **Create Organization** to finish. An **Initial Project** is automatically created in your new organisation. You can be a member of multiple organisations. Use the user menu to switch between them. ## Organisation Settings Access organisation settings by clicking the **settings icon** () next to your organisation name in the user menu. User menu showing settings icon next to organisation For detailed information about all organisation settings, see the [Organisation Settings](/settings/organization-settings) page. From Organisation Settings you can: - **Select organisation** - Switch between organisations you belong to - **Create organisation** - Create a new organisation - **Manage members** - View, invite, and remove members (Admin only) - **Manage invitations** - View and revoke pending invitations (Admin only) - **Delete organisation** - Permanently delete the organisation (Admin only) ## Switching Organisations If you're a member of multiple organisations: Click your **profile icon** in the bottom of the left sidebar. Click on the organisation you want to switch to from the list. The page will refresh to show the selected organisation's projects and data. ## Leaving an Organisation If you want to leave an organisation you're a member of: Open the organisation settings. Scroll down to the **Danger Zone** section. Click **Leave Organisation** and confirm. **You cannot leave if you're the only admin.** Transfer ownership to another member first, or delete the organisation. ## Deleting an Organisation **This action is permanent and cannot be undone.** All projects, tasks, and data will be deleted. Only organisation admins can delete an organisation. Personal organisations cannot be deleted. Danger Zone section with Delete Organization button Open the organisation settings. Scroll down to the **Danger Zone** section. Click **Delete** and confirm when prompted. Before deleting, consider: - Exporting any data you need - Removing all members (they'll lose access immediately upon deletion) - This cannot be recovered ## Organisation Roles Members can have one of two roles: | Role | Capabilities | |------|--------------| | **Admin** | Full access - manage members, settings, and can delete the organisation | | **Member** | Can view and work on projects, but cannot manage organisation settings or members | ### Role Permissions | Action | Admin | Member | |--------|-------|--------| | View projects | ✅ | ✅ | | Create projects | ✅ | ✅ | | Edit project settings | ✅ | ✅ | | Create/edit tasks | ✅ | ✅ | | Invite members | ✅ | ❌ | | Remove members | ✅ | ❌ | | Change member roles | ✅ | ❌ | | Edit organisation settings | ✅ | ❌ | | Delete organisation | ✅ | ❌ | The person who creates an organisation is automatically an Admin. There must always be at least one Admin. ## Best Practices Create one organisation for your team or company. Don't create separate orgs for each project. Choose organisation names that clearly identify the team. "Engineering" is better than "Org 1". Ensure at least two people are admins in case one leaves or is unavailable. Periodically review who has access and remove members who no longer need it. ## Troubleshooting **Problem:** The create organisation button doesn't appear or doesn't work. **Possible causes:** - You're already in the organisation creation flow - Network connectivity issues **Solution:** Refresh the page and try again. If the issue persists, check your browser's console for errors. **Problem:** An organisation you were invited to doesn't appear in your list. **Possible causes:** - The invitation is still pending (check your email) - You signed in with a different account - You were removed from the organisation **Solution:** 1. Check your email for an invitation link 2. Verify you're signed in with the correct account 3. Ask an admin of the organisation to check your membership **Problem:** The "Leave Organisation" button is disabled. **Cause:** You're the only admin. **Solution:** 1. Promote another member to admin first 2. Then you can leave 3. Or delete the organisation if no one else needs it ## Related Documentation - [Team Members](/cloud/team-members) - Inviting and managing organisation members - [Getting Started](/cloud/getting-started) - Initial setup of Vibe Kanban Cloud ================================================ FILE: docs/cloud/projects.mdx ================================================ --- title: "Projects" description: "Create and manage projects to organise your work" --- Projects are where your work lives. Each project contains a kanban board with issues, statuses, and tags that help you organise and track progress. Project kanban board showing columns and issues ## What is a Project? A **project** is a container for related work within your organisation. Think of it as a dedicated space for a specific initiative, product, or team goal. Each project has: - **Issues** - Individual work items (bugs, features, tasks) - **Statuses** - Columns on your kanban board (To Do, In Progress, Done, etc.) - **Tags** - Labels for categorising issues - **Team access** - All organisation members can view and work on project issues ## Project Sidebar The left sidebar provides quick access to your projects and workspaces. Project sidebar showing Workspaces icon, project icons, and add button ### Sidebar Elements | Element | Description | |---------|-------------| | **Workspaces icon** | Top icon - opens the Workspaces view for coding agents | | **Project icons** | Square buttons showing project abbreviations (e.g., "VI" for vibe-kanban) | | **+ button** | Create a new project | ### Project Icons Each project appears as a square button with a two-letter abbreviation of the project name. The currently selected project is highlighted. Hovering over project icon shows full project name Hover over any project icon to see the full project name in a tooltip. ## Creating a Project Sign in and select your organisation from the sidebar or organisation switcher. Click the **+ New Project** button in the sidebar or on the projects page. Fill in the project information: Create Project dialog with name and colour fields | Field | Description | |-------|-------------| | **Name** | A clear name for your project (e.g., "Mobile App", "Q1 Marketing") | | **Colour** | Click the colour swatch to choose a colour for this project | Click **Create Project** to create your project. You'll be taken to your new project's kanban board. ## Default Statuses New projects come with six default status columns: | Status | Purpose | Visible by Default | |--------|---------|-------------------| | **Backlog** | Ideas and future work | Hidden | | **To do** | Issues ready to start | ✓ | | **In progress** | Issues currently being worked on | ✓ | | **In review** | Waiting for code review | ✓ | | **Done** | Completed issues | ✓ | | **Cancelled** | Issues that won't be done | Hidden | Hidden statuses don't appear as columns on the board, but you can still access issues in them via the status tabs (e.g., "Backlog" or "Cancelled" tabs). Project kanban board showing columns and issues You can customise these statuses - rename them, change colours, add new ones, or hide ones you don't use. See [Customising Your Board](/cloud/customisation) for details. ## Project Settings Access project settings by clicking the gear icon () next to the project name. Project header showing settings gear icon ### Renaming a Project 1. Open project settings 2. Edit the **Project name** field 3. Click **Save** ### Changing Project Colour 1. Open project settings 2. Click the colour selector 3. Choose a new colour 4. Click **Save** Edit project form with name and colour picker The colour appears in the sidebar and helps you quickly identify projects. You can also manage projects from **Settings** → **Remote Projects**. See [Remote Projects Settings](/settings/remote-projects) for more details. ## Switching Between Projects You have several ways to switch projects: **From the sidebar:** - Click any project name in the sidebar to open it **From the project dropdown:** - Click the current project name in the header - Select a different project from the dropdown ## Deleting a Project Deleting a project is permanent and cannot be undone. All issues, comments, and data within the project will be deleted. To delete a project, go to **Settings** → **Remote Projects**: Go to **Settings** and select **Remote Projects** from the sidebar. Select the organisation and locate the project you want to delete. Hover over the project and click the **⋯** (three dots) menu. Project row showing three-dots menu button Select **Delete** from the menu and confirm when prompted. Dropdown menu with Delete option Before deleting, consider whether you might need any of the issues or their history. There's no way to recover a deleted project. For more details on managing projects, see [Remote Projects Settings](/settings/remote-projects). ## Best Practices Create separate projects for distinct initiatives rather than putting everything in one project. This keeps your boards focused and manageable. Choose project names that clearly describe what the project is about. "Mobile App v2" is better than "Project A". Try to use similar status columns across projects so team members can switch between projects without confusion. Once issues are done, you can hide the Done column or create an Archive status to keep your board clean. ## Related Documentation - [Issues](/cloud/issues) - Creating and managing issues - [Kanban Board](/cloud/kanban-board) - Using the board interface - [Customising Your Board](/cloud/customisation) - Configuring columns and display settings - [Remote Projects Settings](/settings/remote-projects) - Managing projects from Settings ================================================ FILE: docs/cloud/team-members.mdx ================================================ --- title: "Team Members" description: "Invite colleagues and manage team access to your organisation" --- Collaborate with your team by inviting members to your organisation. Members can view projects, work on tasks, and receive real-time updates. ## Inviting Team Members Only organisation **Admins** can invite new members. Click your **profile icon** in the bottom of the left sidebar, then click the **gear icon** () next to your organisation name. Click the **Invite Member** button in the top-right corner. Enter the email address of the person you want to invite. Use the email they'll sign in with (their GitHub or Google email). If you're unsure, ask them which email is associated with their GitHub/Google account. Choose their role: - **Member** - Can work on projects but can't manage organisation settings - **Admin** - Full access including member management Start with **Member** role. You can always promote them to Admin later if needed. Click **Send Invitation**. They'll receive an email with instructions to join. Invite Member dialog with email and role fields ### What Happens After Inviting 1. The invitee receives an email with an invitation link 2. They click the link and sign in (or create an account) 3. They're automatically added to your organisation 4. They appear in your members list **Invitation links expire after 7 days.** If the invitee doesn't accept in time, you'll need to send a new invitation. ## Viewing Pending Invitations Pending invitations are shown in Organisation Settings above the Members list. Only Admins can see pending invitations. Each pending invitation shows: - **Email address** - Who was invited - **Expiry** - Invitations expire after 7 days - **Revoke button** - Cancel the invitation if needed ## Managing Members ### Viewing Members All current members are listed in Organisation Settings under the **Members** section. Members section showing member list with Invite Member button Each member shows: - **Name** - Their display name and avatar from GitHub/Google - **Role** - Admin or Member ### Changing a Member's Role To promote a member to Admin or demote an Admin to Member: Open Organisation Settings and locate the person in the Members list. Click on their current role badge to open the dropdown. Choose **Admin** or **Member**. The change takes effect immediately. **You cannot demote yourself.** If you're the only admin and want to demote yourself, first promote another member to admin. ### Removing a Member To remove someone from your organisation: Open Organisation Settings and locate the person in the Members list. Click the **Remove** button next to their name. Confirm the removal when prompted. **What happens when you remove someone:** - They immediately lose access to the organisation - They cannot see any projects or data - Their past activity (comments, task updates) remains but shows their name - They can be re-invited later if needed ## Member Roles Explained ### Admin Role Admins have full control over the organisation: | Capability | Description | |------------|-------------| | ✅ All project access | View, create, edit all projects | | ✅ Task management | Create, edit, delete tasks | | ✅ Invite members | Send invitations to new members | | ✅ Remove members | Remove people from the organisation | | ✅ Change roles | Promote members to admin or demote | | ✅ Organisation settings | Edit name, configure settings | | ✅ Delete organisation | Permanently delete the organisation | ### Member Role Members can work on projects but cannot manage the organisation: | Capability | Description | |------------|-------------| | ✅ All project access | View, create, edit all projects | | ✅ Task management | Create, edit, delete tasks | | ❌ Invite members | Cannot send invitations | | ❌ Remove members | Cannot remove people | | ❌ Change roles | Cannot change anyone's role | | ❌ Organisation settings | Cannot edit organisation settings | | ❌ Delete organisation | Cannot delete the organisation | Both roles have **equal access to projects and tasks**. The difference is only in organisation management capabilities. ## Transferring Ownership If you need to transfer full ownership of an organisation (e.g., you're leaving the company): Ensure the person taking over has the Admin role. If you're staying as a regular member, another admin can demote you. If you're leaving entirely, you can now leave the organisation from settings. **There must always be at least one Admin.** The system won't allow the last admin to be demoted or removed. ## Best Practices Give people Member role by default. Only promote to Admin those who need to manage the organisation. Have at least two Admins. If one is unavailable, the other can still manage the organisation. Remove members promptly when they leave your team or company to maintain security. Before inviting, confirm you have the correct email - the one associated with their GitHub/Google account. ## Troubleshooting **Problem:** The person you invited says they didn't get an email. **Solutions:** 1. Ask them to check their spam/junk folder 2. Verify you entered the correct email address 3. Revoke the old invitation and send a new one 4. Have them check if emails from your domain are blocked **Problem:** Clicking the invitation link shows an error. **Possible causes:** - The invitation expired (after 7 days) - The invitation was cancelled - The link was copied incorrectly **Solution:** Send a new invitation from the Members settings page. **Problem:** A new member says they can't see any projects. **Possible causes:** - They signed in with a different account than the one invited - There's a sync delay **Solutions:** 1. Ask them to refresh the page 2. Verify they're signed in with the correct account (check the email in their profile) 3. Check the members list to confirm they appear **Problem:** The invite button is disabled or shows an error. **Possible causes:** - You're not an Admin - You've reached a plan limit (if applicable) **Solutions:** 1. Check your role - only Admins can invite 2. Check if there are billing/plan limitations **Problem:** Someone you removed says they can still access the organisation. **Possible causes:** - Browser cache - They have a different account that's still a member **Solutions:** 1. Ask them to clear their browser cache and refresh 2. Check if they might be signed in with a different account 3. Verify they're actually removed in your members list ## Related Documentation - [Organisations](/cloud/organizations) - Creating and managing organisations - [Authentication](/cloud/authentication) - How sign-in works ================================================ FILE: docs/cloud/troubleshooting.mdx ================================================ --- title: "Troubleshooting" description: "Solutions for common issues with Vibe Kanban Cloud" --- This page covers common issues you might encounter with Vibe Kanban Cloud and how to resolve them. ## Sign-In Issues **Possible causes:** - Pop-up blocker preventing the OAuth window - Network connectivity issues - Browser extensions interfering **Solutions:** 1. Disable pop-up blocker for localhost 2. Check your internet connection 3. Try disabling browser extensions temporarily 4. Try a different browser 5. Clear your browser cache and cookies **Possible causes:** - Pop-up blocker is active - Browser security settings **Solutions:** 1. Look for a blocked pop-up notification in your browser's address bar 2. Allow pop-ups for `localhost` 3. Try right-clicking the sign-in button and selecting "Open in new tab" **Possible causes:** - You clicked "Deny" instead of "Authorize" - Your organisation has app restrictions (GitHub) **Solutions:** 1. Try again and click **Authorize** or **Allow** 2. If using a GitHub organisation account, ask your admin to approve the app **Possible causes:** - Your browser is logged into a different GitHub/Google account **Solutions:** 1. Sign out of Vibe Kanban 2. Go to [github.com](https://github.com) or [google.com](https://google.com) and sign out 3. Sign in with the correct account 4. Return to Vibe Kanban and sign in again **Possible causes:** - Network issues preventing token refresh - Browser blocking cookies **Solutions:** 1. Check your internet connection 2. Ensure cookies are enabled for localhost 3. Try signing out and back in ## Organisation Issues **Possible causes:** - Network connectivity issues - You're not signed in **Solutions:** 1. Ensure you're signed in (check for your avatar in the corner) 2. Check your internet connection 3. Try refreshing the page **Possible causes:** - You haven't accepted the invitation yet - You signed in with a different email - The invitation expired **Solutions:** 1. Check your email for an invitation link and click it 2. Verify you're signed in with the email that was invited 3. Ask the organisation admin to resend the invitation **Possible causes:** - You're the only admin **Solutions:** 1. Promote another member to admin first 2. Then you can leave 3. Or delete the organisation if no one else needs it ## Team Member Issues **Possible causes:** - Email went to spam/junk folder - Wrong email address entered - Email delivery delay **Solutions:** 1. Check spam/junk folder 2. Verify the email address is correct in the pending invitations list 3. Resend the invitation 4. Try a different email address **Possible causes:** - Invitation expired (after 7 days) - Invitation was cancelled - Link was copied incorrectly **Solutions:** 1. Ask for a new invitation to be sent 2. Make sure you're clicking the full link from the email **Possible causes:** - They signed in with a different email - Browser cache issue **Solutions:** 1. Verify they're signed in with the invited email (check their profile) 2. Have them refresh the page 3. Have them sign out and back in ## Project and Board Issues **Possible causes:** - Network connectivity issues - Sync delay **Solutions:** 1. Check your internet connection 2. Refresh the page 3. Sign out and back in **Possible causes:** - Internet connection lost - Temporary sync issue **Solutions:** 1. Check your internet connection 2. Refresh the page - your changes should still be there 3. If changes were lost, you may need to re-enter them **Possible causes:** - The column has issues in it **Solutions:** 1. Move all issues to another column first 2. Check for issues in hidden views (use "All" status tab) 3. Then delete the empty column **Possible causes:** - Sort mode is not set to "Manual" - Browser issue **Solutions:** 1. Check the sort dropdown - select "Manual" to enable drag and drop 2. Try refreshing the page 3. Try a different browser ## Issue Problems **Possible causes:** - Active filters hiding the issue - Issue is in a hidden column **Solutions:** 1. Click **Clear All** in the filter bar to reset filters 2. Use the "All" status tab to see issues in hidden columns 3. Try searching for the issue ID or title **Possible causes:** - Network issue - You closed the panel too quickly **Solutions:** 1. Wait a moment after editing before closing the panel (auto-save has a short delay) 2. Check your internet connection 3. Refresh and check if the change was saved **Possible causes:** - They're not a member of the organisation **Solutions:** 1. Invite them to the organisation first 2. Once they accept, they'll appear in the assignee list ## GitHub Integration Issues **Possible causes:** - GitHub App not installed - Repository not connected **Solutions:** 1. Go to Organisation Settings → GitHub Integration 2. Install the GitHub App if not already installed 3. Ensure the repository has access granted **Possible causes:** - Branch name doesn't include issue ID - PR was created before integration was set up **Solutions:** 1. Include the issue ID in your branch name (e.g., `TASK-123-feature`) 2. Or include the issue ID in the PR title 3. New PRs should link automatically **Possible causes:** - Code review not enabled for the repository **Solutions:** 1. Go to Organisation Settings → GitHub Integration 2. Enable "Code Review" for the repository ## General Issues **Solutions:** 1. Refresh the page 2. Clear browser cache 3. Try a different browser 4. Restart Vibe Kanban (`npx vibe-kanban`) **Possible causes:** - Network connectivity issues - Many browser tabs open **Solutions:** 1. Check your internet connection 2. Close unnecessary browser tabs 3. Refresh the page **Solutions:** 1. Refresh the page to force a sync 2. Check your internet connection 3. Sign out and back in ## Getting Help If you can't resolve an issue: 1. **Check existing issues:** [github.com/BloopAI/vibe-kanban/issues](https://github.com/BloopAI/vibe-kanban/issues) 2. **Report a bug:** Create a new issue with: - What you were trying to do - What happened instead - Your browser and operating system Open a new issue on GitHub for bugs or feature requests ================================================ FILE: docs/configuration-customisation/agent-configurations.mdx ================================================ --- title: "Agent Profiles & Configuration" description: "Configure and customise coding agent variants with different settings for planning, models, and sandbox permissions" --- Agent profiles let you define multiple named variants for each supported coding agent. Variants capture configuration differences like planning mode, model choice, and sandbox permissions that you can quickly select when creating attempts. Agent profiles are used throughout Vibe Kanban wherever agents run: onboarding, default settings, attempt creation, and follow-ups. ## Configuration Access You can configure agent profiles in two ways through Settings → Agents: Use the guided interface with form fields for each agent setting. Agent configuration form editor interface Edit the underlying `profiles.json` file directly for advanced configurations. JSON editor for agent configurations The configuration page displays the exact file path where your settings are stored. Vibe Kanban saves only your overrides whilst preserving built-in defaults. ## Configuration Structure The profiles configuration uses a JSON structure with an `executors` object containing agent variants: ```json profiles.json { "executors": { "CLAUDE_CODE": { "DEFAULT": { "CLAUDE_CODE": { "dangerously_skip_permissions": true } }, "PLAN": { "CLAUDE_CODE": { "plan": true } }, "ROUTER": { "CLAUDE_CODE": { "claude_code_router": true, "dangerously_skip_permissions": true } } }, "GEMINI": { "DEFAULT": { "GEMINI": { "model": "default", "yolo": true } }, "FLASH": { "GEMINI": { "model": "flash", "yolo": true } } }, "CODEX": { "DEFAULT": { "CODEX": { "sandbox": "danger-full-access" } }, "HIGH": { "CODEX": { "sandbox": "danger-full-access", "model_reasoning_effort": "high" } } } } } ``` - **Variant names**: Case-insensitive and normalised to SCREAMING_SNAKE_CASE - **DEFAULT variant**: Reserved and always present for each agent - **Custom variants**: Add new variants like `PLAN`, `FLASH`, `HIGH` as needed - **Built-in protection**: Cannot remove built-in executors, but can override values - Your custom settings override built-in defaults - Built-in configurations remain available as fallbacks - Each variant contains a complete configuration object for its agent ## Agent Configuration Options Enable planning mode for complex tasks Route requests across multiple Claude Code instances Skip permission prompts (use with caution) [View full CLI reference →](https://docs.anthropic.com/en/docs/claude-code/cli-reference#cli-flags) Choose model variant: `"default"` or `"flash"` Run without confirmations [View full CLI reference →](https://google-gemini.github.io/gemini-cli/) Allow all actions without restrictions (unsafe) [View full documentation →](https://ampcode.com/manual#cli) Execution environment: `"read-only"`, `"workspace-write"`, or `"danger-full-access"` Approval level: `"untrusted"`, `"on-failure"`, `"on-request"`, or `"never"` Reasoning depth: `"low"`, `"medium"`, or `"high"` Summary style: `"auto"`, `"concise"`, `"detailed"`, or `"none"` [View full documentation →](https://github.com/openai/codex) Force execution without confirmation Specify model to use [View full CLI reference →](https://docs.cursor.com/en/cli/reference/parameters) Specify model to use Choose agent type [View full documentation →](https://opencode.ai/docs/cli/#flags-1) Run without confirmations [View full documentation →](https://qwenlm.github.io/qwen-code-docs/en/cli/index) Permission level: `"normal"`, `"low"`, `"medium"`, `"high"`, or `"skip-permissions-unsafe"` Specify which model to use Reasoning depth: `"off"`, `"low"`, `"medium"`, or `"high"` [View full documentation →](https://docs.factory.ai/factory-cli/getting-started/overview) ### Universal Options These options work across multiple agent types: Text appended to the system prompt Override the underlying CLI command Additional CLI arguments to pass Options prefixed with "dangerously_" bypass safety confirmations and can perform destructive actions. Use with extreme caution. ## Using Agent Configurations Set your default agent and variant in **Settings → General → Default Agent Configuration** for consistent behaviour across all attempts. Override defaults when creating attempts by selecting different agent/variant combinations in the attempt dialogue. ## Related Configuration MCP (Model Context Protocol) servers are configured separately under **Settings → MCP Servers** but work alongside agent profiles to extend functionality. Configure MCP servers within Vibe Kanban for your coding agents Connect external MCP clients to Vibe Kanban's MCP server ================================================ FILE: docs/configuration-customisation/creating-task-tags.mdx ================================================ --- title: "Creating Task Tags" description: "Create reusable text snippets that can be quickly inserted into task descriptions using @mentions. Task tags are available globally across all projects." --- ## What are task tags? Task tags are reusable text snippets that you can quickly insert into task descriptions by typing `@` followed by the tag name. When you select a tag, its content is automatically inserted at your cursor position. Task tags use snake_case naming (no spaces allowed). For example: `bug_report`, `feature_request`, or `code_review_checklist`. ## Managing task tags Access task tags from **Settings → General → Task Tags**. Tags are available globally across all projects in your workspace. Task tags management interface showing the tag list with names and content Click **Add Tag** to create a new task tag. Create task tag dialogue showing tag name and content fields - **Tag name**: Use snake_case without spaces (e.g., `acceptance_criteria`) - **Content**: The text that will be inserted when the tag is used Click the edit icon (✏️) next to any tag to modify its name or content. Click the delete icon (🗑️) to remove tags you no longer need. Deleting a tag does not affect existing tasks that already have the tag's content inserted. ## Using task tags Insert task tags into task descriptions and follow-up messages using @mention autocomplete. When creating or editing a task description, type `@` to trigger the autocomplete dropdown. Autocomplete dropdown showing available tags after typing @ symbol Continue typing to filter tags by name, then: - Click on a tag to select it - Use arrow keys to navigate and press Enter to select - Press Escape to close the dropdown The tag's content is automatically inserted at your cursor position, replacing the @query. ## Common use cases Create a `bug_report` tag with standardised bug reporting fields: ``` **Description:** **Steps to reproduce:** 1. 2. 3. **Expected behaviour:** **Actual behaviour:** **Environment:** ``` Create an `acceptance_criteria` tag for feature requirements: ``` **Acceptance criteria:** - [ ] Functionality works as specified - [ ] Unit tests added - [ ] Documentation updated - [ ] Accessibility requirements met - [ ] Performance benchmarks passed ``` Create a `code_review` tag with review checklist items: ``` **Code review checklist:** - [ ] Code follows project conventions - [ ] Tests cover edge cases - [ ] No security vulnerabilities introduced - [ ] Performance impact assessed - [ ] Documentation is clear ``` Task tags work in all text fields that support the @mention feature, including task descriptions and follow-up messages, making it easy to maintain consistency across your tasks. ================================================ FILE: docs/configuration-customisation/global-settings.mdx ================================================ --- title: "Global Settings" description: "Configure application-wide settings including themes, agents, and more" sidebarTitle: "Settings" --- You can configure application-wide settings via the **Settings** page. To access it, click the ⚙️ icon in the sidebar or select "Settings" from the top-right menu. Vibe Kanban global settings page showing theme options, agent configuration, and settings ## Themes Switch between light and dark themes to suit your preference. ## Default Agent Configuration Choose the default agent and variant for new task attempts. This profile is pre-selected when creating new task attempts and follow-ups. 1. **Select an agent** (e.g., Claude Code, Gemini CLI, Codex) 2. **Choose a variant** if available (e.g., Default, Plan, Router) You can override the default agent configuration per attempt in the create attempt dialog. ## Editor Integration Configure integration with your preferred code editor for a seamless development workflow. ### Selecting Your Editor Choose from various supported editors: - **VS Code** - Microsoft's popular code editor - **Cursor** - VSCode fork with AI-native features - **Windsurf** - VSCode fork optimised for collaborative development - **Antigravity** - Google's AI-native code editor - **Neovim**, **Emacs**, **Sublime Text** - Other popular editors - **Custom** - Use a custom shell command ### Remote SSH Configuration Vibe Kanban settings editor section showing ssh configuration options. When running Vibe Kanban on a remote server (e.g., accessed via Cloudflare tunnel, ngrok, or as a systemctl service), you can configure VSCode-based editors to open projects via SSH instead of assuming localhost. This feature is available for **VS Code**, **Cursor**, and **Windsurf** editors. #### When to Use Remote SSH Enable remote SSH configuration when: - Vibe Kanban runs on a remote server (VPS, cloud instance, etc.) - You access the web UI through a tunnel or reverse proxy - Your code files are on a different machine than your browser - You want your local editor to connect to the remote server via SSH #### Configuration Fields 1. **Remote SSH Host** (Optional) - The hostname or IP address of your remote server - Examples: `example.com`, `192.168.1.100`, `my-server` - Must be accessible via SSH from your local machine 2. **Remote SSH User** (Optional) - The SSH username for connecting to the remote server - If not specified, SSH will use your default user or SSH config #### How It Works When remote SSH is configured, clicking "Open in VSCode" (or Cursor/Windsurf): 1. Generates a special protocol URL like: `vscode://vscode-remote/ssh-remote+user@host/path/to/project` 2. Opens in your default browser, which launches your local editor 3. Your editor connects to the remote server via SSH 4. The project or task worktree opens in the remote context This works for both project-level and task worktree opening. #### Prerequisites - SSH access configured between your local machine and remote server - SSH keys or credentials set up (no password prompts) - VSCode Remote-SSH extension installed (or equivalent for Cursor/Windsurf) - The remote server path must be accessible via SSH Test your SSH connection first with `ssh user@host` to ensure it works without prompting for passwords. ## Git Configuration Configure git branch naming preferences. ### Branch Prefix Set a prefix for auto-generated branch names (e.g., `vk` results in `vk/task-name`). Leave empty for no prefix. ## Notifications Toggle sound effects and push notifications to stay informed about task status changes. ## Telemetry Enable or disable telemetry data collection to help improve Vibe Kanban. ## Task Tags Manage global task tags to accelerate task creation across all projects. Task tags allow you to define reusable text snippets that can be inserted into task descriptions using @mentions. Complete guide to creating and managing task tags ## Agent Settings (Profiles & Variants) Define and customise agent variants under **Settings → Agents**. Variants let you maintain multiple configurations for the same agent (for example, a Claude Code "PLAN" variant). Detailed guide with examples for configuring agent variants ## Safety & Disclaimers Manage acknowledgments and reset options for onboarding, safety disclaimers, and telemetry notices. - **Onboarding**: Reset the onboarding process to rerun the initial setup. - **Safety Disclaimer**: Reset or review the safety disclaimer prompt. - **Telemetry Notice**: Reset or review the telemetry data collection acknowledgment. ================================================ FILE: docs/configuration-customisation/keyboard-shortcuts.mdx ================================================ --- title: "Keyboard Shortcuts" description: "Keyboard shortcuts available in the Workspaces UI" --- The Workspaces UI provides comprehensive keyboard navigation through the **Keyboard Shortcuts** dialog. ## Accessing Keyboard Shortcuts | Shortcut | Action | |----------|--------| | `⌘/Ctrl + /` | Open Keyboard Shortcuts dialog | Sequential shortcuts require pressing the first key, then the second within 500ms. ## Quick Actions | Shortcut | Action | |----------|--------| | `?` | Show keyboard shortcuts help | | `Esc` | Close/cancel | | `C` | Create new task | | `D` | Delete selected | | `/` | Focus search | ## Modifiers | Shortcut | Action | |----------|--------| | `⌘/Ctrl + K` | Open command bar | | `⌘/Ctrl + E` | Format inline code | | `⌘/Ctrl + Enter` | Send message | ## Navigation | Shortcut | Action | |----------|--------| | `J` | Move down | | `K` | Move up | | `H` | Move left | | `L` | Move right | ## Issue Selection These shortcuts are available on the kanban board and list view. | Shortcut | Action | |----------|--------| | `X` | Toggle current issue selection | | `Shift + J` / `Shift + ↓` | Extend selection down | | `Shift + K` / `Shift + ↑` | Extend selection up | | `⌘/Ctrl + A` | Select all visible issues | | `Esc` | Clear selection | ## Go To (G ...) | Shortcut | Action | |----------|--------| | `G S` | Go to Settings | | `G N` | Go to New Workspace | ## Workspace (W ...) | Shortcut | Action | |----------|--------| | `W D` | Duplicate workspace | | `W R` | Rename workspace | | `W P` | Pin/Unpin workspace | | `W A` | Archive workspace | | `W X` | Delete workspace | ## View (V ...) | Shortcut | Action | |----------|--------| | `V C` | Toggle Changes panel | | `V L` | Toggle Logs panel | | `V P` | Toggle Preview panel | | `V S` | Toggle Left Sidebar | | `V H` | Toggle Chat panel | ## Git (X ...) These shortcuts are available when inside a workspace. | Shortcut | Action | |----------|--------| | `X P` | Create Pull Request | | `X M` | Merge branch | | `X R` | Rebase branch | | `X U` | Push changes | ## Yank (Y ...) These shortcuts are available when inside a workspace. | Shortcut | Action | |----------|--------| | `Y P` | Copy path | | `Y L` | Copy raw logs | ## Toggle (T ...) These shortcuts are available when inside a workspace. | Shortcut | Action | |----------|--------| | `T D` | Toggle dev server | | `T W` | Toggle line wrapping | ## Run (R ...) These shortcuts are available when inside a workspace. | Shortcut | Action | |----------|--------| | `R S` | Run setup script | | `R C` | Run cleanup script | ================================================ FILE: docs/core-features/completing-a-task.mdx ================================================ --- title: "Completing a Task" description: "Learn how to complete tasks by rebasing, merging, and managing pull requests directly from Vibe Kanban" sidebarTitle: "Completing a Task" --- When your task is finished, Vibe Kanban provides integrated git operations to keep your branch up-to-date and merge your work back into the base branch. ## Git Operations Header At the top of the diff view, you'll see important branch information and actions: Diff header showing branch information and action buttons **Branch Information:** - **Task branch**: The branch your task is working on - **Target branch**: The branch you'll merge into (with cog button to change it) - **Commits ahead**: Number of commits your branch has that aren't in the target - **Commits behind**: Number of commits in the target that you don't have (enables rebase button when >0) **Actions:** - **Merge**: Merge your changes into the target branch - **Create PR**: Create a pull request on GitHub - **Rebase**: Update your branch with the latest changes from the target branch ## Rebase Click **Rebase** to update your branch with the latest changes from the target branch. This keeps your branch up-to-date and maintains a clean history. If conflicts occur, see [Resolving Rebase Conflicts](/core-features/resolving-rebase-conflicts). ## Merge Click **Merge** to integrate your completed work into the target branch. Your task will automatically move to the **Done** column. The branch remains until you manually delete it. If you're working with GitHub, consider creating a pull request instead of merging directly. This allows for team review and CI checks. ## Pull Request Management ### Creating a Pull Request Click **Create PR** to create a pull request on GitHub. The title and description are auto-populated from your task details. Header showing disabled Push button after pull request creation After creating the PR, the button changes to **Push** (initially disabled until you make more changes). ### Updating a Pull Request When you continue working after creating a PR, the **Push** button becomes enabled. Click it to push your latest changes to the pull request. Header showing enabled Push button with new changes ready When your PR is merged on GitHub, your task automatically moves to **Done**. ## Related Documentation - [Resolving Rebase Conflicts](/core-features/resolving-rebase-conflicts) - Handle conflicts during rebasing - [GitHub Integration](/integrations/github-integration) - Set up GitHub CLI integration - [Creating Projects](/core-features/creating-projects) - Configure base branches and project settings ================================================ FILE: docs/core-features/creating-projects.mdx ================================================ --- title: "Creating Projects" description: "Learn how to create and configure projects in Vibe Kanban" --- Before you can create tasks and execute coding agents, you must create a project. Create project dialog showing options to create from existing git repository or blank project ## Creating Your Project Click the **Create Project** button to choose from two options: - **From existing git repository**: Browse your file system and select from a list of git repositories sorted by recent activity - **Create blank project**: Generate a new git repository from scratch Each project represents a git repository. After creation, you can configure it with setup scripts, dev server scripts, and other settings. After creating a project, you need to press the settings button in the top right to configure project scripts and settings. ## Project Settings Once you've created a project, you can access the project settings by clicking the settings button in the top right corner. From here, you can configure various aspects of your project. ### Setup Scripts Setup scripts will be run before the coding agent is executed. This is useful for installing dependencies, for example you might run `npm install` or `cargo build`. This will save you time as your agent won't need to figure out that these commands haven't already been run. Each time a coding agent is executed it runs in a [git worktree](https://git-scm.com/docs/git-worktree) which is unlikely to contain your dependencies, configs, .env etc. ### Dev Server Scripts The dev server script is run when you press the "Start Dev Server" button from the [Preview](/core-features/testing-your-application) section. It's useful for quickly reviewing work after a coding agent has run. ### Cleanup Scripts Cleanup scripts run after a coding agent finishes it's turn. You can use these to tidy up the workspace, remove temporary files, or perform any post-execution cleanup. For example, you might run `npm run format` to ensure your code is formatted correctly. Treat it like a git pre-commit hook. ### Copy Files Comma-separated list of files to copy from the original project directory to the worktree. These files will be copied after the worktree is created but before the setup script runs. Useful for environment-specific files like `.env`, configuration files, and local settings. Make sure these files are gitignored or they could get committed! ================================================ FILE: docs/core-features/creating-tasks.mdx ================================================ --- title: "Creating Tasks" description: "Learn how to create and manage tasks on your kanban board, including using templates, starting coding agents, and understanding task states" --- Task creation interface showing Add Task button and form fields After creating a project, add tasks by clicking the **plus (+) icon** in the top right of your project kanban page, or by using the keyboard shortcut **`c`**. You have two options when creating a task: - **Create Task**: Adds the task to your kanban board without starting a coding agent - **Create & Start**: Creates the task and immediately starts it with your default coding agent and current branch ## Using Task Tags Task tag autocomplete dropdown showing available tags after typing @ symbol When adding a task, you can insert reusable text snippets using task tags: 1. Type `@` in the task description or follow-up message 2. Start typing the tag name to filter available tags 3. Select a tag from the dropdown to insert its content Task tags save time by providing reusable text snippets for common task structures. Learn more in the [Task Tags](/configuration-customisation/creating-task-tags) guide. ## Starting an Existing Task Task attempt creation dialog showing agent profile and variant selection options When you open a task that hasn't been attempted yet, you'll see the task title, description, and a list of attempts showing "no attempts yet". Click the **plus (+) button** to create a task attempt and configure: - **Agent profile**: Choose from available agents (e.g., CLAUDE_CODE, GEMINI, CODEX). Your default configuration from Settings is pre-selected. - **Variant**: If your selected agent has variants, pick the appropriate one (e.g., DEFAULT, PLAN). - **Base branch**: Specify which branch the agent should work from. Your current branch is selected by default. Use **Create & Start** to add the task and immediately create a task attempt with your default settings in one action. To monitor your task as it executes, see [Monitoring Task Execution](/core-features/monitoring-task-execution). To understand when you might need multiple attempts, see [New Task Attempts](/core-features/new-task-attempts). ## Creating Tasks via MCP Clients This is not the typical method for creating tasks but can be valuable for bulk task creation, migrating from other systems, using an AI assistant with extra project context, or for coding agents that want to create new tasks. Tasks can also be created programmatically using coding agents or MCP (Model Context Protocol) clients such as Claude Desktop or Raycast. This approach is particularly useful for: - **Bulk task creation** based on existing data or project specifications - **Migration from other systems** like Linear, GitHub Issues, or Jira - **Automated task generation** from project plans or requirements documents For detailed setup instructions and examples, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) documentation. ### Example MCP Task Creation Once configured with an MCP client, you can create multiple tasks from a project description: ``` I need to implement user authentication with: - Email/password registration - Login with session management - Password reset functionality - Email verification - Protected route middleware Please create individual tasks for each component. ``` The MCP client will automatically generate structured tasks in your Vibe Kanban project based on this description. ## Understanding Task Columns Tasks begin in the "To do" column and move automatically based on their progress: | Action | Column | |--------|---------| | Task created | To do | | Task attempt started | In Progress | | Task attempt completed (success or failure) | In Review | | Task attempt merged | Done | | PR merged on GitHub | Done | You can manually drag tasks between columns, but this won't trigger any functionality. Task movement is primarily driven by coding agent actions and GitHub integration (which polls every 60 seconds). ================================================ FILE: docs/core-features/monitoring-task-execution.mdx ================================================ --- title: "Monitoring Task Execution" description: "Learn how to monitor coding agent execution with real-time logs, approvals, and interactive controls" sidebarTitle: "Monitoring Task Execution" --- When you start a task, the main panel provides real-time visibility into everything happening during execution. Watch as agents think through problems, take actions, and respond to your feedback. Task execution showing real-time agent logs with actions and responses ## What Happens During Execution When you start a task attempt, Vibe Kanban orchestrates several steps to create an isolated environment and execute your task: 1. **Git Worktree Creation**: An isolated environment is created for this task attempt 2. **Setup Scripts Run**: Any setup script defined in your project settings runs automatically 3. **Agent Execution**: The coding agent processes your task using the title and description 4. **Real-time Monitoring**: Watch progress through streaming logs in the task interface 5. **Follow-up Questions**: Continue the conversation after execution to refine results ### Git Worktrees Vibe Kanban uses Git worktrees to create isolated environments for each task attempt. These environments are ephemeral and automatically cleaned up after execution completes. Worktrees ensure task attempts don't interfere with each other or your main working directory. ## Execution Flow ### 1. Setup Script The first log you'll see is your project's setup script running (if configured). This installs dependencies and prepares the environment before the agent starts working. ### 2. Task Sent to Agent Your task title and description are sent to the agent. You'll see this as the initial message that kicks off the work. ### 3. Real-Time Actions As the agent works, each action appears in real-time: - **Reasoning**: The agent's thought process as it analyses your task - **Commands**: Shell commands being executed - **File operations**: Files being created, modified, or deleted - **Tool usage**: API calls, searches, and other tool invocations - **Responses**: Agent messages and status updates **Expandable actions**: Click on file change actions to expand them and see exactly which part of the file was modified. ### 4. Action Approvals Approvals are currently supported for Codex, with Claude Code coming soon. When an agent takes an action that requires human approval, a row appears below the action with approve/deny buttons. Approval prompt showing tick and cross buttons to approve or deny an agent action Click the tick to approve or the cross to deny the action. The agent will proceed or adjust based on your decision. ### 5. Cleanup Script After every agent turn, your cleanup script runs (if configured). This is useful for running linters, formatters, or other post-execution tasks. ### 6. Commit Messages Vibe Kanban generates commit messages based on the last message sent by the agent. These automated messages may not always be the most descriptive. When merging to your base branch, use GitHub's "squash & merge" option to rewrite commits with a summary of what actually changed. Alternatively, ask your coding agent to clean up commits manually. ## Interacting During Execution ### Keyboard Shortcuts - **Cmd/Ctrl + Enter**: Send a message to the agent - **Enter**: Create a new line in the message field - **Shift + Tab**: Switch agent profile (e.g., from PLAN to DEFAULT) ### Viewing Task Details Click the task title in the top left to navigate to the task view. Task title in top left corner, click to view full task details This lets you: - See the full task description - Edit the task title or description - View all task attempts ### Editing Previous Messages Message editing is supported by Claude Code, Amp, Codex, Gemini, and Qwen. Editing a previous message with options to save or cancel changes When supported by your coding agent, you can edit previous messages in the conversation. This will revert the agent's work to that point and replay the conversation with your edited message. Editing a message reverts all subsequent agent work. Use this carefully when you need to correct or clarify earlier instructions. ## Viewing Processes Click the triple dot icon in the top right and select **View Processes** to see all running and completed processes. Processes dropdown showing coding agent and development server processes This shows: - Coding agent sessions - Development servers - Build scripts - Any other running processes Each process displays its status and execution timeline. Click any process to view its specific output logs. For development server logs, the recommended way to view them is through [Testing Your Application](/core-features/testing-your-application) where you can see logs alongside the live preview. ## Related Documentation - [Testing Your Application](/core-features/testing-your-application) - Test your application with live preview and dev server logs - [Reviewing Code Changes](/core-features/reviewing-code-changes) - Review the changes agents make - [Creating Projects](/core-features/creating-projects) - Configure setup and cleanup scripts - [Agent Configurations](/configuration-customisation/agent-configurations) - Customise agent behaviour and profiles ================================================ FILE: docs/core-features/new-task-attempts.mdx ================================================ --- title: "New Task Attempts" description: "Understand when and why to create multiple task attempts for fresh restarts with different configurations." sidebarTitle: "New Task Attempts" --- Create new task attempt dialog showing configuration options A task attempt represents a single session with a coding agent against a task. Most tasks only need one attempt, but you may need additional attempts for fresh restarts. ## When to Create New Task Attempts Create a new task attempt when you want to: - **Start from scratch** with a different approach after an unsuccessful attempt - **Try a different coding agent** (e.g., switching from Claude to Codex) - **Use a different agent profile or variant** for specialised behaviour - **Work from a different base branch** to incorporate recent changes - **Reset the conversation context** for a completely fresh start Most users will only need one attempt per task. Only create additional attempts if the first approach didn't work as expected. ## Creating Additional Attempts To create a new task attempt for an existing task: Open the task that needs a fresh attempt. Click the triple dot icon in the top right of the task, then select **Create New Attempt**. Choose your agent profile, variant, and base branch. These can be different from previous attempts. Click **Create Attempt** to begin a fresh execution with the new configuration. ## Impact on Subtasks Creating new task attempts affects subtasks. Subtasks are linked to specific task attempts, not tasks themselves. When you create a new task attempt: - **Existing subtasks** remain linked to their original parent attempt - **New subtasks** created from the new attempt will use the new attempt's branch as their base For more details about how subtasks work with task attempts, see [Creating Subtasks](/core-features/subtasks). ================================================ FILE: docs/core-features/resolving-rebase-conflicts.mdx ================================================ --- title: "Resolving Rebase Conflicts" description: "Learn how to handle rebase conflicts when your base branch has advanced, using either manual resolution or automatic conflict resolution with coding agents." sidebarTitle: "Resolving Rebase Conflicts" --- ## When You See "Rebase Conflicts" After clicking the rebase button, if your changes conflict with the base branch, your task status changes to "Rebase conflicts" and a conflict resolution banner appears. Task showing rebase conflicts status with conflict resolution options The conflict banner provides three options to resolve the situation: Conflict resolution banner showing the three available options - **Resolve Conflicts** - Auto-generate resolution instructions for the coding agent - **Open in Editor** - Manually edit conflicted files - **Abort Rebase** - Cancel and return to previous state ## Resolving Conflicts Automatically The simplest solution is to let the coding agent resolve conflicts automatically: 1. Click **Resolve Conflicts** from the conflict banner to generate specific instructions tailored to your conflict situation and insert them into the follow-up message area. 2. Review the generated instructions and click **Resolve Conflicts** (the Send button changes to this) to have the agent analyse the conflicted files and complete the rebase automatically. Conflict resolution banner with auto-generated instructions in the follow-up field Once the agent completes the resolution, your task status will show *n* commits ahead and the **Merge** button becomes available again. ## Manual Resolution (Alternative) If you prefer to resolve conflicts manually, you have two options: **For single files:** Use **Open in Editor** from the conflict banner to edit one conflicted file at a time. After resolving and refreshing the page, you can press the button again for the next file. **For multiple files (recommended):** Click the triple dot icon at the top right of the task and select **Open in [Your IDE]** to open all worktree files in your chosen IDE, where you can resolve all conflicts at once. Click the triple dot icon at the top right and select **Open in [Your IDE]** to access all worktree files, or use **Open in Editor** from the banner for individual files. Resolve merge markers in each file: ```diff <<<<<<< HEAD (your changes) function newFeature() { return "new implementation"; } ======= function oldFeature() { return "existing implementation"; } >>>>>>> main (base branch changes) ``` After editing all conflicts, stage and continue: ```bash git add . git rebase --continue ``` Automatic resolution works best for most conflicts. Use manual resolution only when you need precise control over the merge decisions. ## Aborting a Rebase If you need to cancel the rebase entirely, click **Abort Rebase** to return to the "Rebase needed" state. You can then try rebasing again or create a new task attempt from the updated base branch. ## Rebasing onto a Different Base Branch If you've changed the base branch of your task attempt and see commits unrelated to your changes, you can use `git rebase --onto` to rebase only your work onto the new base: ```bash git rebase --onto ``` ### Example Scenario You accidentally created a task attempt from the `develop` branch, but it should have been based on `main`. After changing the base branch to `main` in the task settings, you see commits from `develop` that aren't part of your work: ```bash # Find the last commit before your work started (e.g., in the git log) # Then rebase only your commits onto main git rebase 64d504c94d076070d17affd3f84be63b34515445 --onto main ``` This command takes your commits (everything after the specified commit hash) and replays them onto `main`, excluding the unrelated commits from `develop`. Use `git rebase --onto` carefully. Make sure you identify the correct commit hash—the last commit that isn't part of your current task. Consider creating a backup branch first: `git branch backup-branch`. ================================================ FILE: docs/core-features/reviewing-code-changes.mdx ================================================ --- title: "Reviewing Code Changes" description: "Learn how to review and provide feedback on code changes made by coding agents" --- When a coding agent completes a task, it automatically moves to the **In Review** column. This is where you can examine the changes, provide feedback, and ensure the implementation meets your requirements. ## Opening the Code Review Interface Click on any task in the **In Review** column to open it. Click the **Diff icon** to view all the code changes made by the agent. ## Adding Review Comments ### Line-Specific Comments To provide feedback on specific lines of code: Find the line you want to comment on in the diffs view. Click the **plus icon** (+) at the beginning of the line to create a review comment. Plus icon for adding line comments Enter your comment in the text field that appears. You can provide suggestions, ask questions, or request changes. ### Multiple Comments Across Files You can create several review comments across different files in the same review: - Add comments to multiple lines within a single file - Switch between different changed files and add comments to each - All comments will be collected and submitted together as part of your review Review comments are not submitted individually. They are collected and sent as a complete review when you submit your feedback. ## Submitting Your Review Click the **Send** button to send all your feedback to the coding agent. All comments are combined into a single message for the coding agent to address. Once submitted, the task returns to the **In Progress** column where the agent will address your feedback and implement the requested changes. ================================================ FILE: docs/core-features/subtasks.mdx ================================================ --- title: "Creating Subtasks" description: "Learn how to create and manage subtasks to break down complex work into smaller, manageable pieces" --- Subtasks allow you to break down complex tasks into smaller, more manageable pieces. Each subtask is linked to a specific task attempt and inherits the same project and branch context. ## Creating Subtasks Current attempt toolbar showing the Create Subtask button with GitFork icon To create a subtask from an existing task attempt: Open the task you want to create subtasks for. Click the triple dot icon in the top right of the task, then select **Create Subtask**. The task creation dialog opens with the parent task attempt and base branch automatically set. Add your subtask title and description. Click **Save** to create the subtask. It will appear as a new task on your kanban board. When you create a subtask, it automatically inherits the base branch from its parent task attempt, ensuring consistency in your development workflow. ## Viewing Tasks with Subtasks Task view showing a parent task with its associated subtasks listed in the Task Relationships panel When viewing a parent task, you can see its subtasks in the **Task Relationships** panel. This collapsible section shows: - **Child Tasks** with a count (e.g., "CHILD TASKS (1)") - Individual subtask titles with links to view them - Easy navigation between parent and child tasks This helps you track progress across all related work items and understand the task hierarchy at a glance. ## Viewing Subtask Details Subtask detail view showing parent task information in the Task Relationships panel When viewing a subtask, the **Task Relationships** panel displays: - **Parent Task** section showing the parent task title - Direct link to navigate to the parent task - Clear visual indication that this is a child task - Context about the parent-child relationship The subtask also shows its own **Create Subtask** button, allowing you to create nested subtasks if needed. ## How Subtasks Work Subtasks in Vibe Kanban follow these key principles: ### Git Branching Workflow Subtasks create their own feature branches that can work independently while maintaining connection to the parent task: ```mermaid gitGraph commit id: "main" branch feature/parent-task checkout feature/parent-task commit id: "Parent Task Start" commit id: "Initial work" branch feature/subtask-1 checkout feature/subtask-1 commit id: "Subtask 1: Backend API" commit id: "API implementation" commit id: "API tests" checkout feature/parent-task branch feature/subtask-2 checkout feature/subtask-2 commit id: "Subtask 2: Frontend UI" commit id: "Component creation" commit id: "UI styling" checkout feature/parent-task branch feature/subtask-3 checkout feature/subtask-3 commit id: "Subtask 3: Integration" commit id: "Connect API to UI" checkout feature/parent-task merge feature/subtask-1 merge feature/subtask-2 merge feature/subtask-3 commit id: "Parent Task Complete" checkout main merge feature/parent-task ``` ### Parent-Child Relationships - Subtasks are linked to specific **task attempts**, not just tasks - Each subtask knows which attempt created it - Multiple subtasks can be created from the same parent attempt ### Branch Inheritance - Subtasks automatically inherit the base branch from their parent attempt - This ensures subtasks work within the same development context - You can modify the branch when creating the subtask if needed ### Independent Task Lifecycle - Subtasks appear as regular tasks on your kanban board - Each subtask has its own lifecycle (To do → In Progress → In Review → Done) - Subtasks can have their own task attempts and coding agents ================================================ FILE: docs/core-features/testing-your-application.mdx ================================================ --- title: "Testing Your Application" description: "Live preview your web applications with embedded browser viewing and precise component selection for seamless development workflows" sidebarTitle: "Testing Your Application" --- ## Overview Preview Mode provides an embedded browser experience within Vibe Kanban, allowing you to test and iterate on your web applications without leaving the development environment. This feature eliminates the need to switch between your browser and Vibe Kanban by providing live preview capabilities and precise component selection tools. **Key Benefits:** - **Embedded viewing**: See your application running directly in Vibe Kanban - **Precise component selection**: Click to select specific UI components for targeted feedback - **Dev Server Logs**: Monitor development server output with expandable/collapsible logs at the bottom - **Seamless workflows**: No context switching between tools ## Setting Up Preview Mode Navigate to your project settings and configure the development server script that starts your local development environment. **Common examples:** - `npm run dev` (Vite, Next.js) - `npm start` (Create React App) - `yarn dev` (Yarn projects) - `pnpm dev` (PNPM projects) Development server script configuration interface You may also need to configure a setup script (e.g., `npm install`) to install dependencies before the development server starts. Configure this in project settings under Setup Scripts. Ensure your development server prints the URL (e.g., `http://localhost:3000`) to stdout/stderr for automatic detection. For precise component selection, install the `vibe-kanban-web-companion` package in your application. **Recommended**: Use the "Install companion automatically" button in the Preview tab to have Vibe Kanban create a task that installs and configures the companion for you. Install companion automatically button in Preview tab **Manual Installation:** Add the dependency to your project: ```bash npm install vibe-kanban-web-companion ``` Then add the companion to your application: Add to your `src/index.js` or `src/index.tsx`: ```jsx import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion'; import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( ); ``` Add to your `pages/_app.js` or `pages/_app.tsx`: ```jsx import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion' import type { AppProps } from 'next/app' function MyApp({ Component, pageProps }: AppProps) { return ( <> ) } ``` Add to your `src/main.jsx` or `src/main.tsx`: ```jsx import { VibeKanbanWebCompanion } from "vibe-kanban-web-companion"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root")).render( ); ``` The Web Companion is automatically tree-shaken from production builds, so it only runs in development mode. In the Preview section, click the **Start Dev Server** button to start your development server. Starting development server from task interface The system will: - Launch your configured development script - Detect the URL of your website and load it ## Using Preview Mode ### Accessing the Preview Once your development server is running and a URL is detected: 1. **Click the Preview button** (eye icon) in the task interface 2. **View embedded application** in the iframe 3. **Interact with your app** directly within Vibe Kanban Preview mode showing embedded application with toolbar controls ### Preview Toolbar Controls The preview toolbar provides essential controls for managing your preview experience: Preview toolbar showing refresh, copy URL, open in browser, and stop server controls - **Refresh**: Reload the preview iframe - **Copy URL**: Copy the development server URL to clipboard - **Open in Browser**: Open the application in your default browser - **Stop Dev Server**: Stop the running development server ### Dev Server Logs At the bottom of the Preview panel, you'll find Dev Server Logs that can be expanded or collapsed. These logs show real-time output from your development server, making it easy to monitor server activity, errors, and debugging information without leaving the preview. Dev Server Logs showing expandable/collapsible log output at bottom of preview ### Component Selection When the Web Companion is installed, you can precisely select UI components for targeted feedback: Click the floating Vibe Kanban companion button in the bottom-right corner of your application to activate component selection mode. Component selection interface showing selectable elements highlighted When you click a component, Vibe Kanban shows a hierarchy of components from innermost to outermost. Select the appropriate level for your feedback: - **Inner components**: For specific UI elements (buttons, inputs) - **Outer components**: For broader sections (cards, layouts) Component depth selection showing hierarchy of selectable components After selecting a component, write your follow-up message. The coding agent will receive: - **Precise DOM selector** information - **Component hierarchy** and source file locations - **Your specific instructions** about what to change No need to describe "the button in the top right" - the agent knows exactly which component you mean! ## Troubleshooting If the preview doesn't load automatically, ensure your development server prints the URL to stdout/stderr for automatic detection. Supported URL formats: - `http://localhost:3000` - `https://localhost:3000` - `http://127.0.0.1:3000` - `http://0.0.0.0:5173` URLs using `0.0.0.0` or `::` are automatically converted to `localhost` for embedding. ## Related Documentation - [New Task Attempts](/core-features/new-task-attempts) - Learn about task attempt lifecycle - [Reviewing Code Changes](/core-features/reviewing-code-changes) - Analyse and review code modifications - [Configuration & Customisation](/configuration-customisation/global-settings) - Customise Vibe Kanban settings ================================================ FILE: docs/docs.json ================================================ { "$schema": "https://mintlify.com/docs.json", "theme": "mint", "name": "Vibe Kanban", "description": "A kanban board for developers to track coding tasks with AI coding agents", "appearance": { "default": "light" }, "colors": { "primary": "#000000", "light": "#fefefe", "dark": "#121212" }, "background": { "color": { "light": "#FAF9F5", "dark": "#2F2F2D" } }, "favicon": "/logo/v-192.png", "navigation": { "groups": [ { "group": "Getting started", "pages": [ "index", "getting-started", "browser-testing", "issue-management", "reviewing-code", "remote-access", { "group": "Supported Coding Agents", "expanded": true, "pages": [ "supported-coding-agents", "agents/claude-code", "agents/openai-codex", "agents/github-copilot", "agents/gemini-cli", "agents/amp", "agents/cursor-cli", "agents/opencode", "agents/droid", "agents/ccr", "agents/qwen-code" ] } ] }, { "group": "Workspaces", "pages": [ "workspaces/index", "workspaces/creating-workspaces", "workspaces/managing-workspaces", "workspaces/repositories", "workspaces/sessions", "workspaces/chat-interface", "workspaces/slash-commands", "workspaces/interface", "workspaces/command-bar", "workspaces/multi-repo-sessions", "workspaces/changes", "workspaces/git-operations" ] }, { "group": "Cloud", "pages": [ "cloud/index", "cloud/getting-started", "cloud/migration", "cloud/authentication", "cloud/organizations", "cloud/team-members", "cloud/projects", "cloud/issues", "cloud/kanban-board", "cloud/list-view", "cloud/filtering", "cloud/customisation", "cloud/troubleshooting" ] }, { "group": "Settings", "pages": [ "settings/index", { "group": "General", "pages": [ "settings/general", "settings/creating-task-tags" ] }, "settings/projects-repositories", "settings/organization-settings", "settings/remote-projects", "settings/agent-configurations", "settings/mcp-servers" ] }, { "group": "Self-Hosting", "pages": [ "self-hosting/local-development", "self-hosting/deploy-docker" ] }, { "group": "Integrations", "pages": [ "integrations/github-integration", "integrations/azure-repos-integration", "integrations/vscode-extension", "integrations/mcp-server-configuration", "integrations/vibe-kanban-mcp-server" ] }, { "group": "Help", "pages": [ "troubleshooting", "responsible-disclosure" ] } ] }, "logo": { "light": "/logo/light.svg", "dark": "/logo/dark.svg" }, "navbar": { "links": [ { "label": "GitHub", "href": "https://github.com/BloopAI/vibe-kanban" } ], "primary": { "type": "button", "label": "Get Started", "href": "https://vibekanban.com/docs" } }, "contextual": { "options": [ "copy", "view", "chatgpt", "claude", "perplexity", "mcp", "cursor", "vscode" ] }, "integrations": { "posthog": { "apiKey": "phc_V5XpxUvgOfEWk38iGHr0Kve2oZTmWFjDe3mIwTCXzx0", "apiHost": "https://eu.i.posthog.com" } }, "redirects": [ { "source": "/workspaces/preview", "destination": "/browser-testing" }, { "source": "/remote-control", "destination": "/remote-access" } ], "footer": { "socials": { "github": "https://github.com/BloopAI/vibe-kanban", "x": "https://x.com/vibekanban" } } } ================================================ FILE: docs/frontend-ui-library-refactor-audit.md ================================================ # Frontend UI Package Refactor Audit Date: 2026-02-21 ## Scope - Source audited: `packages/local-web/src/components/**/*.tsx` - Total components: 280 - Objective: identify what should move first into a new pnpm UI library package. ## Current Frontend Structure (Component View) | Area | Components | Extract now | Extract later | Keep app | | --- | ---: | ---: | ---: | ---: | | `ui-new/primitives` | 73 | 16 | 8 | 49 | | `ui-new/containers` | 45 | 0 | 0 | 45 | | `ui-new/dialogs` | 33 | 0 | 0 | 33 | | `ui-new/views` | 32 | 0 | 0 | 32 | | `dialogs` | 27 | 0 | 0 | 27 | | `ui/wysiwyg` | 20 | 0 | 20 | 0 | | `ui` | 17 | 11 | 5 | 1 | | `tasks` | 10 | 0 | 0 | 10 | | `NormalizedConversation` | 7 | 0 | 0 | 7 | | `root` | 3 | 0 | 0 | 3 | | `common` | 2 | 0 | 0 | 2 | | `ide` | 2 | 0 | 0 | 2 | | `org` | 2 | 0 | 0 | 2 | | `ui-new/scope` | 2 | 0 | 0 | 2 | | `ui/table` | 2 | 0 | 2 | 0 | | `agents` | 1 | 0 | 0 | 1 | | `settings` | 1 | 0 | 0 | 1 | | `ui-new/terminal` | 1 | 0 | 0 | 1 | ## Refactor Status Legend - `extract-now`: move in first `@vibe/ui` package wave. - `extract-later`: reusable, but move only after API/dependency decoupling. - `keep-app`: stay in frontend app package (feature/domain/integration UI). ## Recommended First Extraction Set - Start with `extract-now` components from `components/ui` and selected `components/ui-new/primitives`. - Keep all `containers`, `views`, and feature `dialogs` in the app package. - Treat `components/ui/wysiwyg` as a separate future package (`@vibe/editor-ui`) after `@vibe/ui` lands. ## Full Component Map ### ui-new/primitives | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/primitives/Accordion.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/AppBar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/AppBarButton.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/AppBarSocialLink.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/AppBarUserPopover.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/AutoResizeTextarea.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/ChatBoxBase.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/CollapsibleSectionHeader.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/stores. | | `ui-new/primitives/ColorPicker.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/Command.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/CommandBar.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. Requires decoupling from @/components/*. | | `ui-new/primitives/CommentCard.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/ContextBar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/ContextUsageGauge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/CreateChatBox.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/Dialog.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. | | `ui-new/primitives/Dropdown.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. | | `ui-new/primitives/EmojiPicker.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. Requires decoupling from @/contexts. | | `ui-new/primitives/ErrorAlert.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/GoogleLogo.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/IconButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/IconButtonGroup.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/InputField.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/KanbanAssignee.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/KanbanBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/MultiSelectCommandBar.tsx` | `extract-later` | `@vibe/ui` | Potentially reusable but needs API cleanup. | | `ui-new/primitives/MultiSelectDropdown.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/OAuthButtons.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/Popover.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. | | `ui-new/primitives/PrBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/PrimaryButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/PriorityIcon.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/ProcessListItem.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/PropertyDropdown.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/RelationshipBadge.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/RepoCard.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/RunningDots.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/SearchableDropdown.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/SearchableTagDropdown.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/SessionChatBox.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/SplitButton.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/StatusDot.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/SubIssueRow.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/SyncErrorIndicator.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/TodoProgressPopup.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/Toggle.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/Toolbar.tsx` | `extract-now` | `@vibe/ui` | Primitive UI component; good first package candidate. | | `ui-new/primitives/Tooltip.tsx` | `extract-later` | `@vibe/ui` | Primitive UI component; good first package candidate. Move after decoupling @/contexts. | | `ui-new/primitives/UserAvatar.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/ViewNavTabs.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/WorkspaceSummary.tsx` | `keep-app` | `frontend-app` | Domain-specific primitive for kanban/chat/workspace flows. | | `ui-new/primitives/conversation/ChatAggregatedDiffEntries.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatAggregatedToolEntries.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatApprovalCard.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatAssistantMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatCollapsedThinking.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatEntryContainer.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatErrorMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatFileEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatMarkdown.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatScriptEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatScriptPlaceholder.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatSubagentEntry.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatSystemMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatThinkingMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatTodoList.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatToolSummary.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ChatUserMessage.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/PierreConversationDiff.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/conversation/ToolStatusDot.tsx` | `keep-app` | `frontend-app` | Conversation domain rendering components. | | `ui-new/primitives/model-selector/ModelList.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. | | `ui-new/primitives/model-selector/ModelProviderIcon.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. | | `ui-new/primitives/model-selector/ModelSelectorPopover.tsx` | `keep-app` | `frontend-app` | Model/provider domain UI. | ### ui-new/containers | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/containers/AppBarUserPopoverContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ChangesPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ColorPickerContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/CommentWidgetLine.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ContextBarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ConversationListContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/CopyButton.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/CreateChatBoxContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/CreateModeRepoPickerBar.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/FileTreeContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/GitHubCommentRenderer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/GitPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/IssueCommentsSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/IssueRelationshipsSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/IssueSubIssuesSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/IssueWorkspacesSectionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/KanbanContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/KanbanIssuePanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/LogsContentContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/MigrateChooseProjectsContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/MigrateFinishContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/MigrateIntroductionContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/MigrateLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/MigrateMigrateContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ModelSelectorContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/NavbarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/NewDisplayConversationEntry.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/PierreDiffCard.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/PreviewBrowserContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/PreviewControlsContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ProcessListContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ProjectRightSidebarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/RemoteIssueLink.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/ReviewCommentRenderer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/RightSidebar.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/SearchableDropdownContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/SearchableTagDropdownContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/SessionChatBoxContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/SharedAppLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/TerminalPanelContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/VirtualizedProcessLogs.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/WorkspaceNotesContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/WorkspacesLayout.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/WorkspacesMainContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | | `ui-new/containers/WorkspacesSidebarContainer.tsx` | `keep-app` | `frontend-app` | State/data container; keep with app feature logic. | ### ui-new/dialogs | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/dialogs/AssigneeSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/ChangeTargetDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/CommandBarDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/ConfirmDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/CreateRepoDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/DeleteWorkspaceDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/ErrorDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/GuideDialogShell.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/KanbanFiltersDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/KeyboardShortcutsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/ProjectsGuideDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/RebaseDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/RebaseInProgressDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/RenameWorkspaceDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/ResolveConflictsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/SelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/SettingsDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/WorkspaceSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/WorkspacesGuideDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/selections/ProjectSelectionDialog.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/AgentsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/ExecutorConfigForm.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/GeneralSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/McpSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/OrganizationsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/RemoteProjectsSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/ReposSettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/SettingsComponents.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/SettingsDirtyContext.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/SettingsSection.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/rjsf/Fields.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/rjsf/Templates.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | | `ui-new/dialogs/settings/rjsf/Widgets.tsx` | `keep-app` | `frontend-app` | Workflow/dialog feature UI tied to app flows. | ### ui-new/views | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/views/ChangesPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/FileTree.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/FileTreeNode.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/FileTreeSearchBar.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/GitPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueCommentsSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueListRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueListSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueListView.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssuePropertyRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueRelationshipsSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueSubIssuesSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueTagsRow.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueWorkspaceCard.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/IssueWorkspacesSection.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/KanbanBoard.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/KanbanCardContent.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/KanbanFilterBar.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/KanbanIssuePanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/MigrateChooseProjects.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/MigrateFinish.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/MigrateIntroduction.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/MigrateMigrate.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/MigrateSidebar.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/Navbar.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/PreviewBrowser.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/PreviewControls.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/PreviewNavigation.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/PriorityFilterDropdown.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/TerminalPanel.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/WorkspacesMain.tsx` | `keep-app` | `frontend-app` | Feature view composition. | | `ui-new/views/WorkspacesSidebar.tsx` | `keep-app` | `frontend-app` | Feature view composition. | ### dialogs | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `dialogs/CreateWorkspaceFromPrDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/auth/GhCliSetupDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/git/ForcePushDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/global/OAuthDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/global/ReleaseNotesDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/org/CreateOrganizationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/org/CreateRemoteProjectDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/org/DeleteRemoteProjectDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/org/InviteMemberDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/scripts/ScriptFixerDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/settings/CreateConfigurationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/settings/DeleteConfigurationDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/shared/ConfirmDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/shared/FolderPickerDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/shared/LoginRequiredPrompt.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/ChangeTargetBranchDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/CreatePRDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/EditBranchNameDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/EditorSelectionDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/GitActionsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/PrCommentsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/RebaseDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/RestoreLogsDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/StartReviewDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/TagEditDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/tasks/ViewProcessesDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `dialogs/wysiwyg/ImagePreviewDialog.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### ui/wysiwyg | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui/wysiwyg/context/task-attempt-context.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/context/typeahead-open-context.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/lib/create-decorator-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/nodes/component-info-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/nodes/image-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/hooks, @/components/*. | | `ui/wysiwyg/nodes/pr-comment-node.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/clickable-code-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/code-highlight-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/component-info-keyboard-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/components/*, @/contexts, @/lib/*api, @/stores. | | `ui/wysiwyg/plugins/image-keyboard-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/keyboard-commands-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/markdown-sync-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/paste-markdown-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/read-only-link-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/slash-command-typeahead-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/contexts, @/hooks. | | `ui/wysiwyg/plugins/static-toolbar-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | | `ui/wysiwyg/plugins/toolbar-plugin.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/contexts. | | `ui/wysiwyg/plugins/typeahead-menu-components.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. | ### ui | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui/alert.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/auto-expanding-textarea.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/badge.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/button.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/card.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/checkbox.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/dialog.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/keyboard. | | `ui/dropdown-menu.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. | | `ui/input.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/label.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/loader.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/pr-comment-card.tsx` | `keep-app` | `frontend-app` | PR domain card component. | | `ui/select.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. | | `ui/switch.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/textarea.tsx` | `extract-now` | `@vibe/ui` | Core shadcn-style primitive with low business coupling. | | `ui/tooltip.tsx` | `extract-later` | `@vibe/ui` | Reusable but currently tied to app context/portal behavior. Requires decoupling from @/contexts. | | `ui/wysiwyg.tsx` | `extract-later` | `@vibe/editor-ui` | Editor subsystem; split as a dedicated package later. Requires decoupling from @/vscode. | ### tasks | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `tasks/AgentSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/BranchSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/ConfigSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/RepoBranchSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/RepoSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/TaskDetails/ProcessLogsViewer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/TaskDetails/ProcessesTab.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/Toolbar/GitOperations.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/UserAvatar.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `tasks/VariantSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### NormalizedConversation | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `NormalizedConversation/DisplayConversationEntry.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/EditDiffRenderer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/FileChangeRenderer.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/FileContentView.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/PendingApprovalEntry.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/RetryEditorInline.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `NormalizedConversation/UserMessage.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### root | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ConfigProvider.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. | | `TagManager.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. | | `ThemeProvider.tsx` | `keep-app` | `frontend-app` | App bootstrap/provider concern. | ### common | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `common/ProfileVariantBadge.tsx` | `keep-app` | `frontend-app` | App-specific utility presentation component. | | `common/RawLogText.tsx` | `keep-app` | `frontend-app` | App-specific utility presentation component. | ### ide | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ide/IdeIcon.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `ide/OpenInIdeButton.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### org | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `org/MemberListItem.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | | `org/PendingInvitationItem.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### ui-new/scope | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/scope/NewDesignScope.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). | | `ui-new/scope/VSCodeScope.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). | ### ui/table | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui/table/data-table.tsx` | `extract-later` | `@vibe/ui` | Reusable table building blocks; defer until core package is stable. | | `ui/table/table.tsx` | `extract-later` | `@vibe/ui` | Reusable table building blocks; defer until core package is stable. | ### agents | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `agents/AgentIcon.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### settings | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `settings/ExecutorProfileSelector.tsx` | `keep-app` | `frontend-app` | Feature component tied to app domain/data. | ### ui-new/terminal | Component | Status | Target package | Notes | | --- | --- | --- | --- | | `ui-new/terminal/XTermInstance.tsx` | `keep-app` | `frontend-app` | Runtime integration (scope, terminal, keyboard, IDE). | ## Summary Counts - extract-now: 27 - extract-later: 35 - keep-app: 218 ## Suggested Package Split - `packages/ui`: design tokens, core primitives, small reusable composed controls. - `packages/editor-ui` (later): WYSIWYG/editor-specific nodes and plugins. - `frontend`: feature views/containers/dialogs and integration-heavy components. ================================================ FILE: docs/getting-started.mdx ================================================ --- title: "Get Started" description: "Launch Vibe Kanban, connect a coding agent, and go from zero to pull request" sidebarTitle: "Get Started" --- Vibe Kanban keeps you organised while running multiple coding agents in parallel by streamlining how you plan and review their work. ## 1. Launch Vibe Kanban Start the Vibe Kanban client and open the UI in your browser: ```bash npx vibe-kanban ``` ## 2. Confirm your preferences The first time you run Vibe Kanban, you'll be asked to set your preferred: - Coding agent - IDE - Notification preferences These preferences can be changed at any time from the settings dialog Placeholder image for preferences setup: coding agent, IDE, and sound notifications. ## 3. Sign-in to Vibe Kanban You can use a GitHub or Google account. Vibe Kanban onboarding screen showing Sign in to continue with GitHub and Google buttons If you want to skip sign-in for now, click **More options** → **I understand, continue without signing in**. You'll still be able to create workspaces, but the kanban board, issues, and team features will be unavailable. ## 4. Navigate the kanban board After you sign in, we automatically create a personal organisation and an initial project for you, and take you straight there. The projects page **Navigating the kanban board:** 1. The app bar, used for navigating between projects, the workspaces page (we'll come onto this later) and user settings 2. Issues appear as cards on the kanban board 3. The 'new issue' button, for creating issues 4. The right hand panel where details for the currently selected or draft issue is shown ## 5. Create an issue **Issues** are a core concept of Vibe Kanban, they represent a bug, feature or piece of work to be done. At a minimum, issues consist of a title and description, but you can also add priorities, tags and even connect issues together with parent/child relationships. Create issue When you've filled out the details press 'create issue'. ## 6. Create a workspace **Workspaces** are another core concept of Vibe Kanban, they represent a space to work on an issue with a coding agent. When you create a workspace, Vibe Kanban automatically creates git worktrees for your selected repositories, and launches your coding agent. The create workspace button To create a workspace, make sure the issue you created is selected and click the 'create' button in the workspaces window. Workspaces repos When you create a workspace, you'll need to specify repositories you'd like to work on, as well as the branches of those repositories to base the git worktrees on. Workspaces prompt You'll also need to specify your desired coding agent configuration (e.g. model, effort level, plan mode). Workspaces logs Upon creation, the coding agent will immediately begin executing with the given prompt. You can connect multiple workspaces to an issue, this is useful for working on larger features and allows you to run multiple coding agents in parallel. Workspaces don't *have* to be connected to an issue, which is useful for quick actions like asking questions about a codebase. ## 7. Reviewing a workspace So far we've been viewing the workspace side-by-side with our kanban board. However, if we want more room to review the code changes or test them in a browser, we can open the workspace in the **workspaces view**. Workspaces open To access the workspaces view, click the **Open Workspace** button. Workspaces page Depending on what you'd like to do, you can view code changes or preview changes to websites in a browser using the floating navigation buttons (3). You can also navigate back to either your project (1) or the parent issue (2). ## 8. Merging a workspace When you're ready to merge the changes in a workspace, you can either open a GitHub pull request or merge the workspace branch locally. Workspaces merge ## Next steps Set up a dev server, preview your app, and click-to-component to jump straight to source Automate dependency installs, builds, and teardown so every workspace starts clean Review diffs, prompt the agent with feedback, and iterate before merging Break large features into smaller pieces and track progress across sub-issues ================================================ FILE: docs/index.mdx ================================================ --- title: "Vibe Kanban" description: "Plan and review the work of AI agents faster, ship more" sidebarTitle: "Home" --- In a world where software engineers spend most of their time planning and reviewing coding agents, the most impactful way to ship more is to get faster at planning and review. Vibe Kanban is built for this. Use kanban issues to plan work, either privately or with your team. When you’re ready to begin, create workspaces where coding agents can execute. We're not a coding agent, but work seamlessly with a handful of the most popular options like Claude Code, Codex, Gemini CLI and OpenCode. ```bash npx vibe-kanban ``` One command. Describe the work, review the diff, ship it. Vibe Kanban workspace showing the logs panel with command output from running processes ================================================ FILE: docs/integrations/azure-repos-integration.mdx ================================================ --- title: "Azure Repos Integration" description: "Connect to Azure Repos to create pull requests and manage your workflow directly from Vibe Kanban" --- Vibe Kanban integrates with Azure Repos to let you create pull requests directly from your task attempts. This integration relies on the [Azure CLI (`az`)](https://learn.microsoft.com/en-us/cli/azure/) with the Azure DevOps extension being installed and authenticated on your system. ## Setup Before you can create pull requests from Vibe Kanban, you need to install and configure the Azure CLI manually. ### Install Azure CLI Follow the [official installation instructions](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) for your operating system: - **macOS**: `brew install azure-cli` - **Windows**: Download and run the MSI installer from the Azure CLI documentation - **Linux**: Use your distribution's package manager or the install script ### Install the Azure DevOps Extension The Azure DevOps extension adds repository and pull request commands to the Azure CLI. Run the following command: ```bash az extension add --name azure-devops ``` ### Authenticate 1. **Sign in to Azure**: Run the following command and follow the prompts to authenticate via the web browser: ```bash az login ``` 2. **Configure defaults** (optional but recommended): Set your default organisation and project to avoid specifying them with each command: ```bash az devops configure --defaults organization=https://dev.azure.com/{your-org} project={your-project} ``` ## Supported URL Formats Vibe Kanban supports both modern and legacy Azure DevOps URL formats: - **Modern**: `https://dev.azure.com/{org}/{project}/_git/{repo}` - **Legacy**: `https://{org}.visualstudio.com/{project}/_git/{repo}` Both HTTPS and SSH remote URLs are supported. ## Creating a Pull Request Once the Azure CLI is ready, you can create pull requests directly from a task: 1. Open a task that has changes you want to merge. 2. Click the **Create PR** button. 3. A dialog will appear pre-filled with: * **Title**: Derived from the task title. * **Description**: Derived from the task description. * **Base Branch**: The target branch for your changes (defaults to the repository's default branch or the one specified in the attempt). 4. Click **Create** to open the PR on Azure Repos. If the operation is successful, the task status will update, and a link to the new Pull Request will be available. ================================================ FILE: docs/integrations/github-integration.mdx ================================================ --- title: "GitHub Integration" description: "Connect to GitHub to create pull requests and manage your workflow directly from Vibe Kanban" --- Vibe Kanban integrates with GitHub to let you create pull requests directly from your task attempts. This integration relies on the [GitHub CLI (`gh`)](https://cli.github.com/) being installed and authenticated on your system. ## Setup There is no need to configure GitHub in the Vibe Kanban settings manually. The integration is designed to work out-of-the-box if you have the GitHub CLI installed. ### Automatic Setup When you attempt to create your first Pull Request, Vibe Kanban will check if the GitHub CLI is available. If it's not found or not authenticated, you will be guided through the setup process: 1. **On macOS**: You will see a dialog offering to install the GitHub CLI via Homebrew automatically. 2. **On Windows/Linux**: You will be provided with instructions to install the CLI manually. ### Manual Setup You can also set up the integration manually in your terminal before using Vibe Kanban: 1. **Install GitHub CLI**: Follow the [official installation instructions](https://github.com/cli/cli#installation) for your operating system. 2. **Authenticate**: Run the following command in your terminal and follow the prompts: ```bash gh auth login ``` Select **GitHub.com**, choose **HTTPS** or **SSH** as your preferred protocol, and complete the login via the web browser. ## Creating a Pull Request Once the GitHub CLI is ready, you can create pull requests directly from a task: 1. Open a task that has changes you want to merge. 2. Click the **Create PR** button. 3. A dialog will appear pre-filled with: * **Title**: Derived from the task title. * **Description**: Derived from the task description. * **Base Branch**: The target branch for your changes (defaults to the repository's default branch or the one specified in the attempt). 4. Click **Create** to open the PR on GitHub. If the operation is successful, the task status will update, and a link to the new Pull Request will be available. ================================================ FILE: docs/integrations/mcp-server-configuration.mdx ================================================ --- title: "Connecting MCP Servers" description: "Configure MCP (Model Context Protocol) servers to enhance your coding agents within Vibe Kanban with additional tools and capabilities." --- This page covers configuring MCP servers **within** Vibe Kanban for your coding agents. For connecting external MCP clients to Vibe Kanban's MCP server, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) guide. ## Overview MCP servers provide additional functionality to coding agents through standardized protocols. You can configure different MCP servers for each coding agent in Vibe Kanban, giving them access to specialized tools like browser automation, access to remote logs, error tracking via Sentry, or documentation from Notion. ## Accessing MCP Server Configuration 1. Navigate to **Settings** in the Vibe Kanban interface 2. Click on **MCP Servers** in the settings sidebar 3. Select the coding agent you want to configure MCP servers for from the **Agent** dropdown MCP Server configuration page showing agent selection, JSON configuration, and popular servers ## Popular MCP Servers Vibe Kanban provides one-click installation for popular MCP servers. Click a card to insert a pre-configured MCP server into the JSON configuration. Popular MCP servers including Vibe Kanban, Context7, Playwright, Exa, Chrome DevTools, and Dev Manager ## Adding Custom MCP Servers You can also add your own MCP servers by configuring them manually: Choose the coding agent you want to configure MCP servers for from the **Agent** dropdown. In the **Server Configuration (JSON)** editor, add your custom MCP server configuration. The JSON will show the current configuration for the selected agent, and you can modify it to include additional servers. Example addition: ```json { "mcpServers": { "existing_server": { "command": "npx", "args": ["-y", "some-existing-server"] }, "my_custom_server": { "command": "node", "args": ["/path/to/my-server.js"] } } } ``` After updating the JSON configuration: 1. Click **Save** to apply changes 2. Test the configuration by using the agent with MCP functionality 3. Check agent logs for any connection issues These changes update the global configuration file of the coding agent and will persist even if you stop using Vibe Kanban. ## Best Practices **Server Selection**: Choose MCP servers that complement your coding agent's primary function. For example, use Playwright for agents focused on web development. **Limit MCP Servers**: Avoid adding too many MCP servers to a single coding agent. Too many servers and tools will degrade the effectiveness of coding agents by overwhelming them with options. ## Next Steps - Explore the [Agent Configurations](/settings/agent-configurations) guide for advanced agent setup - Check out [Supported Coding Agents](/supported-coding-agents) for agent-specific features ================================================ FILE: docs/integrations/vibe-kanban-mcp-server.mdx ================================================ --- title: "Vibe Kanban MCP Server" description: "Configure the Vibe Kanban MCP server" --- Vibe Kanban exposes a local MCP (Model Context Protocol) server, allowing you to manage organisations, projects, issues, workspaces, and repositories from external MCP clients like Claude Desktop, Raycast, or even coding agents running within Vibe Kanban itself. This page covers connecting **external MCP clients** to Vibe Kanban's MCP server. For configuring MCP servers **within** Vibe Kanban for your coding agents, see the [MCP Server Configuration](/integrations/mcp-server-configuration) guide. Vibe Kanban's MCP server is **local-only** - it runs on your computer and can only be accessed by applications installed locally. It cannot be accessed via publicly accessible URLs. ## Setting Up MCP Integration ### Option 1: Using the Web Interface This works if you're adding the Vibe Kanban MCP server to any [supported coding agent](/supported-coding-agents) **within** Vibe Kanban. 1. In Vibe Kanban Settings, navigate to the "MCP Servers" page 2. In the "Popular servers" section, click on the Vibe Kanban card 3. Click the `Save Settings` button MCP Servers configuration page showing how to add Vibe Kanban MCP server ### Option 2: Manual Configuration You can manually add the MCP server to your coding agent's configuration. The exact syntax will depend on your coding agent or MCP client. Add the following configuration to your agent's MCP servers configuration: ```json { "mcpServers": { "vibe_kanban": { "command": "npx", "args": ["-y", "vibe-kanban@latest", "--mcp"] } } } ``` `--mcp` launches the local MCP stdio server. Any additional arguments after `--mcp` are passed through to the `vibe-kanban-mcp` binary. ## Available MCP Tools The Vibe Kanban MCP server provides tools for managing organisations, projects, issues, workspaces, and task execution. Many tools accept an optional `project_id` or `organization_id` parameter. When running inside a workspace linked to a remote project, these are inferred automatically from context and can be omitted. The exception is `list_projects`, which always requires an explicit `organization_id`. ### Context | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `get_context` | Get current workspace context (only available within an active workspace session) | None | None | Project, issue, and workspace metadata | ### Organisation Operations | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_organizations` | List all available organisations | None | None | List of organisations with IDs, names, and slugs | | `list_org_members` | List members of an organisation | None | `organization_id` | List of members with user IDs, roles, and profile info | ### Project Operations | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_projects` | List projects in an organisation | `organization_id` | None | List of projects with IDs and names | ### Issue Management | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_issues` | List issues in a project | None | `project_id`
`status`
`priority`
`search`
`simple_id`
`parent_issue_id`
`assignee_user_id`
`tag_id`
`tag_name`
`limit`
`offset` | Paginated list of issues with PR info | | `create_issue` | Create a new issue | `title` | `project_id`
`description`
`priority`
`parent_issue_id` | Created issue ID | | `get_issue` | Get detailed issue information | `issue_id` | None | Full issue details with tags, relationships, sub-issues, and PRs | | `update_issue` | Update an existing issue | `issue_id` | `title`
`description`
`status`
`priority`
`parent_issue_id` | Updated issue details | | `delete_issue` | Delete an issue | `issue_id` | None | Deletion confirmation | | `list_issue_priorities` | List allowed priority values | None | None | List of priorities: urgent, high, medium, low | For `update_issue`, the `parent_issue_id` field supports three states: omit it entirely to leave the parent unchanged, pass `null` to un-nest the issue from its parent, or pass a UUID to set a new parent. ### Issue Assignees | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_issue_assignees` | List assignees for an issue | `issue_id` | None | List of assignees with user IDs | | `assign_issue` | Assign a user to an issue | `issue_id`
`user_id` | None | Issue assignee ID | | `unassign_issue` | Remove an assignee from an issue | `issue_assignee_id` | None | Unassignment confirmation | ### Issue Tags | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_tags` | List tags for a project | None | `project_id` | List of tags with IDs, names, and colours | | `list_issue_tags` | List tags attached to an issue | `issue_id` | None | List of issue-tag relations | | `add_issue_tag` | Attach a tag to an issue | `issue_id`
`tag_id` | None | Issue-tag relation ID | | `remove_issue_tag` | Remove a tag from an issue | `issue_tag_id` | None | Removal confirmation | ### Issue Relationships | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `create_issue_relationship` | Create a relationship between two issues | `issue_id`
`related_issue_id`
`relationship_type` | None | Relationship ID | | `delete_issue_relationship` | Delete a relationship between issues | `relationship_id` | None | Deletion confirmation | Supported relationship types: `blocking`, `related`, `has_duplicate`. ### Repository Management | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_repos` | List all repositories | None | None | List of repositories with IDs and names | | `get_repo` | Get repository details including scripts | `repo_id` | None | Repository info with setup, cleanup, and dev server scripts | | `update_setup_script` | Update a repository's setup script | `repo_id`
`script` | None | Update confirmation | | `update_cleanup_script` | Update a repository's cleanup script | `repo_id`
`script` | None | Update confirmation | | `update_dev_server_script` | Update a repository's dev server script | `repo_id`
`script` | None | Update confirmation | ### Workspace Management | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `list_workspaces` | List local workspaces | None | `archived`
`pinned`
`branch`
`name_search`
`limit`
`offset` | Paginated list of workspaces | | `update_workspace` | Update a workspace's properties | None | `workspace_id`
`archived`
`pinned`
`name` | Updated workspace details | | `delete_workspace` | Delete a local workspace | None | `workspace_id`
`delete_remote`
`delete_branches` | Deletion confirmation | | `link_workspace_issue` | Link a workspace to a remote issue | `workspace_id`
`issue_id` | None | Link confirmation | ### Workspace Sessions | Tool | Purpose | Required Parameters | Optional Parameters | Returns | |------|---------|-------------------|-------------------|---------| | `start_workspace` | Create a workspace and start its first coding-agent session | `name`
`executor`
`repositories` | `prompt`
`variant`
`issue_id` | Workspace ID | | `create_session` | Create a session in an existing workspace | None | `workspace_id`
`executor` | Session summary | | `list_sessions` | List sessions for a workspace | None | `workspace_id` | Session list | | `run_session_prompt` | Run a coding-agent prompt inside an existing session | `session_id`
`prompt` | None | Execution details | | `get_execution` | Inspect execution status and final message | `execution_id` | None | Execution details | The `repositories` parameter is an array of objects with: - `repo_id`: The repository ID (UUID) - `branch`: The branch for this repository When `issue_id` is provided, the workspace is automatically linked to the remote issue. If `prompt` is omitted, the linked issue's title and description are used as the workspace prompt. ### Supported Executors When using `start_workspace`, the following executors are supported (case-insensitive, accepts hyphens or underscores): - `claude-code` / `CLAUDE_CODE` - `amp` / `AMP` - `gemini` / `GEMINI` - `codex` / `CODEX` - `opencode` / `OPENCODE` - `cursor_agent` / `CURSOR_AGENT` - `qwen-code` / `QWEN_CODE` - `copilot` / `COPILOT` - `droid` / `DROID` ## Using the MCP Server Once you have the MCP server configured, you can leverage it to streamline your project planning and execution workflow: 1. **Plan Your Work**: Describe a large feature or project to your MCP client 2. **Request Issue Creation**: At the end of your description, simply add "then turn this plan into issues" 3. **Automatic Issue Generation**: Your MCP client will use the Vibe Kanban MCP server to automatically create structured issues in your project 4. **Start Workspaces**: Use `start_workspace` to programmatically begin work on issues with specific coding agents ## Example Usage ### Planning and Issue Creation ``` I need to build a user authentication system with the following features: - User registration with email validation - Login/logout functionality - Password reset capability - Session management - Protected routes Then turn this plan into issues. ``` Your MCP client will use the `create_issue` tool to break this down into individual issues and add them to your Vibe Kanban project automatically. ### Starting a Workspace Session After issues are created, you can start work on them programmatically: ``` Start working on the user registration issue using Claude Code on the main branch. ``` Your MCP client will use the `start_workspace` tool with parameters like: ```json { "name": "User registration with email validation", "executor": "claude-code", "repositories": [ { "repo_id": "987fcdeb-51a2-3d4e-b678-426614174001", "branch": "main" } ], "issue_id": "123e4567-e89b-12d3-a456-426614174000" } ``` This creates a new workspace, links it to the issue, generates a feature branch, and starts the coding agent in an isolated environment. ### Complete Workflow Example ``` 1. List organisations to find the organisation ID 2. List projects in the organisation to find the project ID 3. List issues in the project filtered by status 4. Create a new issue for "Add user profile page" 5. Assign a team member to the issue 6. Start a workspace session for the issue using Amp on the develop branch ``` Each step uses the appropriate MCP tool (`list_organizations`, `list_projects`, `list_issues`, `create_issue`, `assign_issue`, `start_workspace`) to manage the complete workflow from planning to execution. ### Internal Coding Agents (Within Vibe Kanban) A powerful workflow involves using coding agents within Vibe Kanban that are also connected to the Vibe Kanban MCP server: 1. **Create a Planning Issue**: Create an issue with a custom agent profile configured with a planning prompt. See [Agent Configurations](/settings/agent-configurations) for details on creating custom profiles. 2. **Explore and Plan**: The coding agent explores the codebase and develops a comprehensive plan 3. **Generate Issues**: Ask the coding agent to "create a series of individual issues for this plan" 4. **Automatic Population**: The agent uses the MCP server to populate individual issues directly in the Vibe Kanban interface This creates a seamless workflow where high-level planning automatically generates actionable issues in your project board. ## Installation Instructions for MCP Clients ### Raycast Example Raycast is a popular MCP client that can connect to Vibe Kanban's MCP server. Here's how to configure it: For complete Raycast MCP configuration details, see the [official Raycast MCP documentation](https://manual.raycast.com/model-context-protocol). Raycast MCP configuration - adding Vibe Kanban server Configure the Vibe Kanban MCP server in Raycast by adding the server details. Raycast MCP configuration - server successfully added Once configured, you'll see the Vibe Kanban MCP server listed and ready to use in Raycast. Similar configuration steps apply to other MCP clients like Claude Desktop, VS Code with MCP extensions, or any custom MCP client implementations. ================================================ FILE: docs/integrations/vscode-extension.mdx ================================================ --- title: "VSCode Extension Integration" description: "Complete guide to using the Vibe Kanban extension with VSCode, Cursor, and Windsurf" --- The Vibe Kanban VSCode extension brings task management directly into your IDE, providing seamless integration with logs, diffs, and process monitoring. This extension works with VSCode and popular forks including Cursor and Windsurf. ## Installation Install directly from the Visual Studio Code Marketplace: Install the official Vibe Kanban extension for VSCode Alternatively, search for the extension ID in VSCode: 1. Open VSCode 2. Press `Ctrl+Shift+X` (Windows/Linux) or `Cmd+Shift+X` (macOS) to open Extensions 3. Search for `@id:bloop.vibe-kanban` 4. Click **Install** For Cursor, use the Open VSX Registry: Install from Open VSX for Cursor compatibility Since deeplinking from Open VSX doesn't work reliably in Cursor, the easiest installation method is searching by extension ID within the IDE. **Installation Steps**: 1. Open Cursor 2. Open the Extensions panel 3. Search for `@id:bloop.vibe-kanban` 4. Install the extension For Windsurf, use the Open VSX Registry: Install from Open VSX for Windsurf compatibility Since deeplinking from Open VSX doesn't work reliably in Windsurf, the easiest installation method is searching by extension ID within the IDE. **Installation Steps**: 1. Open Windsurf 2. Open the Extensions panel 3. Search for `@id:bloop.vibe-kanban` 4. Install the extension ## Features The extension provides an integrated workspace view with three main components: ### Logs View - List of task attempts for the current task - Agent steps performed in each task attempt ### Diffs View - Side-by-side comparison of code changes - Inline commenting and review capabilities ### Processes View - Monitor running task processes - Process status indicators (running, completed, failed) ### Task Management - **Task Iteration**: Continue iterating on the current ongoing task - **Status Updates**: Real-time task status synchronization ## Usage Workflow Navigate to your project's kanban board and create or select an existing task. Click on the task to open its detailed view. Click the **"Open in VSCode"**, **"Open in Cursor"**, or **"Open in Windsurf"** button depending on your preferred IDE. The button text will reflect your editor choice configured in Vibe Kanban settings. Your IDE will open in the task's dedicated worktree with the extension UI populated with: - Current task context - Real-time logs - Code diffs - Process monitoring ## Troubleshooting ### Empty Extension UI **Problem**: The extension UI appears empty or shows no task information. **Solution**: Ensure you're working within a worktree created by a Vibe Kanban task. The extension can only display task information when VSCode is opened in a directory that corresponds to an active Vibe Kanban task worktree. **Steps to resolve**: 1. Verify you opened VSCode through the "Open in [IDE]" button from a Vibe Kanban task 2. Check that you're in the correct directory/worktree 3. If the issue persists, restart your IDE and try the workflow again ### Extension Not Loading **Problem**: The Vibe Kanban extension doesn't appear in your IDE. **Solutions**: - Verify the extension is installed by searching `@id:bloop.vibe-kanban` in Extensions - Restart your IDE after installation - Check that you're using a compatible IDE version - For Cursor/Windsurf users, ensure you installed from Open VSX Registry ## Best Practices **Workflow Optimization**: Always start tasks from the Vibe Kanban web interface before opening in your IDE to ensure proper context and worktree setup. **Performance**: Close unused IDE windows to reduce resource usage. ## Supported IDEs | IDE | Status | Installation Method | |-----|--------|-------------------| | **VSCode** | ✅ Fully Supported | VSCode Marketplace | | **Cursor** | ✅ Fully Supported | Open VSX Registry | | **Windsurf** | ✅ Fully Supported | Open VSX Registry | The extension is designed to work with any VSCode-compatible editor. If you're using a different VSCode fork, try installing from Open VSX Registry using the extension ID `bloop.vibe-kanban`. ================================================ FILE: docs/issue-management.mdx ================================================ --- title: "Issue Management" description: "Create, organise, and track issues from first draft to done on the kanban board" --- Issues are the fundamental unit of work in Vibe Kanban. They represent a bug, feature, or piece of work to be done — and they become the prompt your coding agent receives when you create a workspace. This guide walks you through the full issue lifecycle. ## 1. Create your first issue You can create an issue from the **+** button on any column header, or from the **New Issue** button in the filter bar. Kanban board showing the New Issue button in the header and the plus button on a column Use the **Team** and **Personal** tabs to switch between all project issues and only those assigned to you. Filter bar showing the Team and Personal tabs Give your issue a clear, specific title that describes the outcome you want. The issue panel opens on the right where you can fill in the details. New issue panel with title field and description editor ## 2. Write an effective description The description is where you provide context for your coding agent. Use the rich text editor to format your instructions with bold, italic, lists, and inline code. You can also use markdown shortcuts for headings (`#`) and links (`[text](url)`). Issue description editor with formatted text including headings and code blocks A good description makes the difference between an agent that delivers exactly what you need and one that goes off track. | Element | Weak example | Strong example | |---------|-------------|----------------| | **Title** | "Fix bug" | "Fix login timeout on slow connections" | | **Description** | "It's broken" | "Users on 3G see a timeout after 5 s. Expected: graceful retry with exponential backoff." | Your issue description becomes the prompt your coding agent receives. The more specific you are, the better the result. ## 3. Set priority, assignee, and tags Use priority to signal urgency, assign the issue to a team member, and add tags to categorise your work. Issue panel showing the priority dropdown and tags selector | Priority | When to use | |----------|-------------| | **Urgent** | Production is down or users are blocked | | **High** | Important work that should be picked up next | | **Medium** | Standard work in the current sprint | | **Low** | Nice-to-have improvements or future ideas | Tags are project-specific. You can manage them from the tag selector when editing an issue. ## 4. Manage issues on the kanban board Drag and drop issues between columns to change their status, or within a column to reorder them. Kanban board showing issues being dragged between columns | Column | What it means | |--------|---------------| | **To do** | Work that hasn't started yet | | **In progress** | An agent or person is actively working on this | | **In review** | Work is done and waiting for your review | | **Done** | Completed and verified | The **Backlog** and **Cancelled** columns are hidden by default — switch to the **All** tab to see them. All tab selected showing Backlog and Cancelled statuses alongside the default columns To reorder issues within a column, make sure sorting is set to **Manual**. Other sort modes (Priority, Created, Updated) override manual ordering. ## 5. Break work down with sub-issues Large features are easier to manage when you break them into smaller, agent-sized tasks. Sub-issues let you create parent/child relationships between issues. To add a sub-issue, open the parent issue and scroll to the **Sub-Issues** section. You have two options: click the **+** button to create a new sub-issue, or click the link button to connect an existing issue as a child. Sub-issues section showing child issues with mixed statuses Key rules for sub-issues: - Each sub-issue has its own **independent status** — completing all children does not auto-complete the parent - Sub-issues show a **Parent** link so you can navigate back to the parent issue ## 6. Connect issues to workspaces A workspace is where your coding agent does the actual work. Open an issue and scroll to the **Workspaces** section. You have two options: click the **+** button to create a new workspace, or click the link button to connect an existing one. Issue panel showing the Workspaces section with Create and link buttons You can connect multiple workspaces to a single issue — useful for running agents in parallel on different parts of the same feature. ## Troubleshooting - Check your active filters — click **Clear All** to reset them - The issue might be in **Backlog** or **Cancelled** — switch to the **All** tab to see hidden statuses Sort mode must be set to **Manual**. If sorting is set to Priority, Created, or another mode, drag-to-reorder is disabled. Change the sort option in the board header. Changes auto-save after a brief delay. Check your internet connection if changes aren't persisting, and try refreshing the page. ## Next steps Find issues quickly with search, filters, and sorting Learn more about the board layout and collaboration Understand how workspaces connect to issues ================================================ FILE: docs/remote-access.mdx ================================================ --- title: "Remote Access" description: "Access your local Vibe Kanban instance from another device" --- Remote Access allows you to access a host instance of Vibe Kanban from another device, like your mobile phone. # 1. Launch Vibe Kanban on the host device Make sure you've started Vibe Kanban and logged in. # 2. Generate a pairing code Open the settings dialog, navigate to the `Remote Access` tab and click `Show pairing code`: Remote Access settings page on the host device with the Show pairing code button Make a note of the pairing code: Pairing code modal on the host device showing the generated code # 3. Log into Vibe Kanban cloud On the client device where you'd now like to access your host machine from, navigate to [cloud.vibekanban.com](https://cloud.vibekanban.com) in a browser and log in. # 4. Pair your client with your host On your client, navigate to `Remote Access` and click `Link a host`. Select the relevant host from the dropdown: Remote Access page on mobile showing the Pair host form with host selection and pairing code input Enter the pairing code you generated earlier and click `Pair host`: Remote Access page on mobile with the host dropdown opened before pairing # 5. Access workspaces from your host device You can now exit the settings page. You should see the paired host, clicking on the host will list the host's workspaces. Vibe Kanban cloud remote access page on mobile showing a paired host and workspaces ================================================ FILE: docs/responsible-disclosure.mdx ================================================ --- title: "Responsible Disclosure" description: "How to report security vulnerabilities to Vibe Kanban responsibly" --- **Last updated:** *February 28, 2026* At **Vibe Kanban**, we take the security of our platform and the safety of our customers' data seriously. We welcome responsible reports of potential security vulnerabilities to help us identify and resolve issues quickly and securely. ## How to Report a Security Issue If you believe you've discovered a vulnerability in Vibe Kanban that falls within scope, please send an email to: [**security@bloop.com**](mailto:security@bloop.com) When submitting a report, include the following where possible: - **Summary** of the vulnerability and its potential impact - **Steps to reproduce** the issue (screenshots or clear descriptions help) - **Environment details** (OS, browser, device, etc.) - **Proof-of-concept code** or any relevant exploit details Upon receipt of your report, we will: 1. Acknowledge it in a timely manner. 2. Investigate and triage the issue. 3. Communicate with you for clarification or retesting if needed. 4. Work to remediate the issue and keep you updated. ## What's In Scope The following services and assets are currently in scope for responsible disclosure: - https://cloud.vibekanban.com (remote server) - https://relay.vibekanban.com (relay server) In most cases, we will only reward the following types of vulnerabilities: - Arbitrary code execution - SQL injection If you are unsure whether something is in scope, please contact us before testing. ## What's Out of Scope To ensure everyone's safety and to focus on issues that genuinely affect our users, the following are considered out of scope: - Automated scanning without prior coordination - Social engineering targeting Vibe Kanban personnel - Rate-limiting or missing headers that do not lead to material harm - Brute force or denial-of-service attacks - Attacks requiring physical access to systems or interception of another user's network traffic - Theoretical vulnerabilities without a practical proof of exploitability ## Please Do Not - Access or modify any data that does not belong to you - Disrupt our services or cause downtime - Share details of the issue publicly before we have had a chance to fix it ## Report Format Recommendations To help us diagnose issues efficiently, reports should include: - A clear **summary and title** of the issue - Affected URL(s) or components - Exact **steps to reproduce**, including screenshots where appropriate - Environment and version details - Proof-of-concept code or payloads ## Safe Harbour & Recognition We respect the efforts of security researchers who act in good faith and follow this Responsible Disclosure policy. Provided you comply with this policy, **Vibe Kanban will not pursue legal action** against individuals reporting vulnerabilities responsibly. Researchers who submit valid and impactful reports may also receive **recognition** or other discretionary rewards, at Vibe Kanban's sole discretion. ## Confidentiality All information you share with us as part of your report will be handled confidentially. We will not disclose sensitive details publicly before remediation, and we will coordinate with you if public acknowledgement is planned. ## Bounty & Rewards Vibe Kanban may offer monetary rewards for qualifying vulnerability reports. For critical, well-documented disclosures that demonstrate clear impact (such as remote code execution), we may pay up to $5,000 USD per vulnerability. Rewards are determined at our sole discretion and depend on factors such as: - Severity and impact of the issue - Quality and clarity of the report - Reproducibility - Whether the vulnerability is previously unknown Not all reports will qualify for a reward. ================================================ FILE: docs/reviewing-code.mdx ================================================ --- title: "Reviewing Code" description: "Review diffs, add inline comments, and send feedback to your coding agent" --- After a coding agent finishes work, you review its changes, leave inline comments, and send the agent back to address them. This review-feedback-fix loop repeats until you are satisfied. ## 1. Open the changes panel There are three ways to open the changes panel: - Click the **Toggle Changes Panel** button in the navbar - Click the changes icon in the **app bar** - Open the [command bar](/workspaces/command-bar) from its icon in the navbar or with `Cmd/Ctrl + K`, then search for "Show Changes Panel" Workspace showing the Toggle Changes Panel button in the navbar The changes panel shows the diff viewer in the centre and the file tree on the right. Changes panel showing the file tree on the left and diff viewer on the right ## 2. Navigate the file tree The file tree shows all files that were added, modified, or deleted. Click any file to load its diff in the viewer. File tree with folders expanded and search box visible Use the search box at the top to filter files by name. The toggle button in the search bar lets you expand or collapse all folders at once. Start with the files you care most about — the main feature file or the entry point — rather than reviewing alphabetically. This gives you context for the rest of the changes. ## 3. Read diffs The diff viewer uses colour coding to show what changed: green for additions, red for deletions, and grey for unchanged context lines. You can switch between two view modes: | Mode | Best for | How to switch | |------|----------|---------------| | **Unified** | Quick scanning, small changes | Diff view toggle in the navbar | | **Side-by-Side** | Large refactors, comparing old vs new | Same toggle, or `Cmd/Ctrl + K` → "Switch to Side-by-Side View" | Unified diff view showing interleaved additions and deletions Side-by-side diff view showing additions in green and deletions in red You can also switch views from the [command bar](/workspaces/command-bar) (`Cmd/Ctrl + K` → "Switch to Side-by-Side View"). ## 4. Add inline comments To leave feedback on a specific line, hover over it in the diff and click the comment icon that appears. Write your comment and submit it. Inline comment being written on a diff line with the Add Review Comment button Good comments are specific and actionable. Here are some examples: | Comment type | Example | |-------------|---------| | **Request a change** | "This endpoint should validate the user ID before querying the database." | | **Ask a question** | "Why did you choose a Map here instead of a plain object?" | | **Provide context** | "This function is called from the auth middleware — it needs to handle expired tokens." | Comments are not sent individually. They are collected and submitted together when you send a message in the chat. ## 5. Send feedback to the agent After adding your comments, send them to the agent. You can include an optional message for extra context, or just send the comments on their own. A badge shows how many review comments will be included. Chat showing review comments being sent to the agent The agent sees all inline comments as context and works through them. ## Troubleshooting - The agent may not have made any changes yet — check the workspace status - If all changes were committed and pushed, the panel resets. Check the Git section for the latest commit. Comments are consumed when you send a message. They become part of the chat history. Add new comments for the next review round. Be more specific. Instead of "this is wrong", explain what is wrong and suggest a fix. Reference exact line numbers or variable names so the agent knows exactly where to look. ## Next steps Create and organise issues on the kanban board Preview your app in the built-in browser Full reference for diff viewer and comment features Create pull requests, rebase, merge, and manage branches ================================================ FILE: docs/self-hosting/deploy-docker.mdx ================================================ --- title: "Deploy with Docker Compose" description: "Deploy Vibe Kanban Cloud on any server using Docker Compose" --- This guide covers deploying Vibe Kanban Cloud on any Linux server using Docker Compose. This approach works with any cloud provider (AWS, DigitalOcean, Hetzner, etc.) or on-premises server. ## Prerequisites - A Linux server with: - **Docker** and **Docker Compose** v2.0+ installed - **2GB RAM** minimum (4GB recommended) - **10GB disk space** - A **domain name** pointing to your server - **SSL certificate** (we'll use Caddy for automatic HTTPS) - OAuth credentials from [GitHub](https://github.com/settings/developers) or [Google](https://console.cloud.google.com/apis/credentials) ## Step 1: Prepare Your Server SSH into your server and install Docker if not already installed: ```bash # Install Docker (Ubuntu/Debian) curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # Log out and back in for group changes to take effect ``` Clone the Vibe Kanban repository: ```bash git clone https://github.com/BloopAI/vibe-kanban.git cd vibe-kanban ``` ## Step 2: Configure OAuth Update your OAuth application callback URLs to use your domain: - **Authorization callback URL**: `https://your-domain.com/v1/oauth/github/callback` - **Authorized redirect URI**: `https://your-domain.com/v1/oauth/google/callback` ## Step 3: Create Environment File Generate a secure JWT secret: ```bash openssl rand -base64 48 ``` Create `.env.remote` in the repository root: ```env .env.remote # Required secrets VIBEKANBAN_REMOTE_JWT_SECRET= ELECTRIC_ROLE_PASSWORD= DB_PASSWORD= # Your domain DOMAIN=your-domain.com # Relay API base URL (required if you enable relay/tunnel) VITE_RELAY_API_BASE_URL=https://relay.your-domain.com # OAuth — configure at least one provider. Leave the other empty or remove it. GITHUB_OAUTH_CLIENT_ID=your_github_client_id GITHUB_OAUTH_CLIENT_SECRET=your_github_client_secret GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= # Email (optional — leave empty to disable invitation emails) LOOPS_EMAIL_API_KEY= ``` ## Step 4: Create Production Docker Compose Create `docker-compose.prod.yml` in the `crates/remote` directory: ```yaml docker-compose.prod.yml services: caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" environment: DOMAIN: ${DOMAIN} volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config depends_on: - remote-server remote-db: image: postgres:16-alpine command: ["postgres", "-c", "wal_level=logical"] restart: unless-stopped environment: POSTGRES_DB: remote POSTGRES_USER: remote POSTGRES_PASSWORD: ${DB_PASSWORD:-remote} volumes: - remote-db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U remote -d remote"] interval: 5s timeout: 5s retries: 5 start_period: 5s electric: image: electricsql/electric:1.3.3 working_dir: /app restart: unless-stopped environment: DATABASE_URL: postgresql://electric_sync:${ELECTRIC_ROLE_PASSWORD}@remote-db:5432/remote?sslmode=disable PG_PROXY_PORT: 65432 LOGICAL_PUBLISHER_HOST: electric AUTH_MODE: insecure ELECTRIC_INSECURE: true ELECTRIC_MANUAL_TABLE_PUBLISHING: true ELECTRIC_USAGE_REPORTING: false ELECTRIC_FEATURE_FLAGS: allow_subqueries,tagged_subqueries volumes: - electric-data:/app/persistent depends_on: remote-db: condition: service_healthy remote-server: condition: service_healthy remote-server: build: context: ../.. dockerfile: crates/remote/Dockerfile args: VITE_RELAY_API_BASE_URL: ${VITE_RELAY_API_BASE_URL:-} restart: unless-stopped depends_on: remote-db: condition: service_healthy environment: RUST_LOG: info,remote=info SERVER_DATABASE_URL: postgres://remote:${DB_PASSWORD:-remote}@remote-db:5432/remote SERVER_LISTEN_ADDR: 0.0.0.0:8081 ELECTRIC_URL: http://electric:3000 SERVER_PUBLIC_BASE_URL: https://${DOMAIN} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET} ELECTRIC_ROLE_PASSWORD: ${ELECTRIC_ROLE_PASSWORD} LOOPS_EMAIL_API_KEY: ${LOOPS_EMAIL_API_KEY:-} healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8081/v1/health"] interval: 5s timeout: 5s retries: 10 start_period: 10s volumes: remote-db-data: electric-data: caddy_data: caddy_config: ``` ## Step 5: Create Caddyfile Create a `Caddyfile` in the `crates/remote` directory for automatic HTTPS (core app/API): ```text Caddyfile {$DOMAIN} { reverse_proxy remote-server:8081 } ``` This base deployment serves the main Cloud app/API only. Relay/tunnel support is optional and requires additional relay routing plus wildcard DNS/TLS for your relay domain. ## Step 6: Deploy ```bash cd crates/remote # Build and start all services docker compose --env-file ../../.env.remote -f docker-compose.prod.yml up -d --build # View logs docker compose -f docker-compose.prod.yml logs -f ``` The first build takes 10-15 minutes. Subsequent deployments are faster as Docker caches the build layers. ## Step 7: Verify Deployment 1. Open `https://your-domain.com` in your browser 2. You should see the Vibe Kanban Cloud login page 3. Sign in with your OAuth provider 4. Create your first organisation and project ## Optional: Enable Relay/Tunnel in Production Relay/tunnel support requires: 1. A running `relay-server` service 2. Reverse proxy routing for both `relay.your-domain.com` and `*.relay.your-domain.com` 3. A wildcard certificate for `*.relay.your-domain.com` (or equivalent managed TLS at your edge) 4. `VITE_RELAY_API_BASE_URL` set to your public relay API base URL before building `remote-server` ### Add relay-server to docker compose ```yaml docker-compose.prod.yml relay-server: build: context: ../.. dockerfile: crates/relay-tunnel/Dockerfile restart: unless-stopped depends_on: remote-db: condition: service_healthy environment: RUST_LOG: info SERVER_DATABASE_URL: postgres://remote:${DB_PASSWORD:-remote}@remote-db:5432/remote RELAY_LISTEN_ADDR: 0.0.0.0:8082 VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET} ``` ### Add relay proxy routes Your reverse proxy must route: - `relay.your-domain.com` -> `relay-server:8082` - `*.relay.your-domain.com` -> `relay-server:8082` Standard ACME HTTP challenge does not issue wildcard certificates. For wildcard relay hostnames, use a DNS-based ACME challenge or another edge provider that can terminate wildcard TLS certificates. ## Updating To update to a new version: ```bash cd vibe-kanban git pull origin main cd crates/remote docker compose --env-file ../../.env.remote -f docker-compose.prod.yml up -d --build ``` ## Backup and Restore ### Backup Database ```bash docker compose -f docker-compose.prod.yml exec remote-db \ pg_dump -U remote remote > backup_$(date +%Y%m%d).sql ``` ### Restore Database ```bash docker compose -f docker-compose.prod.yml exec -T remote-db \ psql -U remote remote < backup_20240101.sql ``` ## Monitoring ### View Logs ```bash # All services docker compose -f docker-compose.prod.yml logs -f # Specific service docker compose -f docker-compose.prod.yml logs -f remote-server ``` ### Check Service Health ```bash docker compose -f docker-compose.prod.yml ps ``` ## Troubleshooting Caddy automatically obtains SSL certificates from Let's Encrypt. Ensure: - Your domain's DNS is correctly pointing to your server - Ports 80 and 443 are open in your firewall - Your domain is correctly set in the environment The server may start before the database is ready. Check: ```bash docker compose -f docker-compose.prod.yml logs remote-db docker compose -f docker-compose.prod.yml restart remote-server ``` ElectricSQL requires the `electric_sync` database user, which the Remote Server creates automatically on first startup. If ElectricSQL cannot connect: 1. Check that the Remote Server started successfully and ran its migrations 2. Verify `ELECTRIC_ROLE_PASSWORD` matches in both your `.env.remote` and the Electric service config 3. Restart ElectricSQL after the Remote Server is healthy: ```bash docker compose -f docker-compose.prod.yml restart electric ``` If the build fails with memory errors, you may need a server with more RAM or add swap: ```bash sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile ``` ================================================ FILE: docs/self-hosting/local-development.mdx ================================================ --- title: "Local Development" description: "Run Vibe Kanban Cloud locally" --- This guide walks you through setting up a local Vibe Kanban Cloud instance for development and testing. ## Prerequisites Before you begin, ensure you have: - **Docker** and **Docker Compose** installed - **Git** to clone the repository - **Node.js 20+** and **pnpm** (for running the desktop client) - A **GitHub** or **Google** OAuth application ## Step 1: Clone the Repository ```bash git clone https://github.com/BloopAI/vibe-kanban.git cd vibe-kanban ``` ## Step 2: Create OAuth Application You need at least one OAuth provider. Choose GitHub, Google, or both. 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click **New OAuth App** 3. Fill in the details: - **Application name**: Vibe Kanban Local - **Homepage URL**: `http://localhost:3000` - **Authorization callback URL**: `http://localhost:3000/v1/oauth/github/callback` 4. Click **Register application** 5. Copy the **Client ID** 6. Click **Generate a new client secret** and copy it 1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 2. Create a new project or select an existing one 3. Click **Create Credentials** → **OAuth client ID** 4. Select **Web application** 5. Add authorized redirect URI: `http://localhost:3000/v1/oauth/google/callback` 6. Click **Create** 7. Copy the **Client ID** and **Client Secret** ## Step 3: Configure Environment Create a `.env.remote` file in `crates/remote/`: ```bash # Generate a secure JWT secret openssl rand -base64 48 ``` Copy the output and create your `.env.remote`: ```env .env.remote # Required - JWT secret for authentication VIBEKANBAN_REMOTE_JWT_SECRET= # Optional - Password for ElectricSQL database role (electric_sync user) ELECTRIC_ROLE_PASSWORD= # OAuth — configure at least one provider. Leave the other empty or remove it. GITHUB_OAUTH_CLIENT_ID=your_github_client_id GITHUB_OAUTH_CLIENT_SECRET=your_github_client_secret # Google OAuth (leave empty if not using Google login) GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= # Relay (required for relay/tunnel features) # For plain HTTP local dev: VITE_RELAY_API_BASE_URL=http://localhost:8082 # Email invitations (optional — leave empty to disable) LOOPS_EMAIL_API_KEY= ``` For production or self-hosting on a server, add `PUBLIC_BASE_URL` (your public URL, e.g. `https://kanban.example.com`) and `REMOTE_SERVER_PORTS=0.0.0.0:3000:8081` so the server is reachable from other hosts. Defaults keep local dev unchanged. Never commit `.env.remote` to version control. It's already in `.gitignore`. ## Step 4: Start the Stack From the `crates/remote` directory, start all services: ```bash cd crates/remote docker compose --env-file .env.remote -f docker-compose.yml up --build ``` Or from the repo root: ```bash pnpm run remote:dev ``` This starts: - **PostgreSQL** on port 5433 (external) / 5432 (internal) - **ElectricSQL** on port 3000 (internal only, used by Remote Server for real-time sync) - **Remote Server** on port 3000 (external) / 8081 (internal) - **Relay Server** on port 8082 (external and internal) The remote server binds to `127.0.0.1:3000` only - it's not accessible from other machines. For production, use a reverse proxy. Wait until you see health checks passing: ``` remote-server-1 | INFO remote: Server listening on 0.0.0.0:8081 ``` ## Step 5: Access the Web Interface Open [http://localhost:3000](http://localhost:3000) in your browser. You should see the Vibe Kanban Cloud login page. Sign in with your configured OAuth provider (GitHub or Google). ## Step 6: Connect the Desktop Client (Optional) To use the desktop client with your local server: ```bash # In a new terminal, from the repository root export VK_SHARED_API_BASE=http://localhost:3000 pnpm install pnpm run dev ``` The desktop client will now connect to your local Cloud instance instead of the hosted version. To test relay/tunnel mode end-to-end, add: ```bash export VK_SHARED_API_BASE=https://localhost:3001 export VK_SHARED_RELAY_API_BASE=https://relay.localhost:3001 export VK_TUNNEL=1 ``` This mode requires local HTTPS + Caddy routing (next step). ## Step 7: Optional Local HTTPS + Caddy (required for tunnel-mode testing) Create a Caddy config that routes: - `localhost:3001` -> remote server (`127.0.0.1:3000`) - `relay.localhost:3001` and `*.relay.localhost:3001` -> relay server (`127.0.0.1:8082`) ```bash caddy run --config - --adapter caddyfile <<'EOF' localhost:3001, relay.localhost:3001, *.relay.localhost:3001 { tls internal @relay host relay.localhost *.relay.localhost handle @relay { reverse_proxy 127.0.0.1:8082 } @app expression `{http.request.host} == "localhost:3001" || {http.request.host} == "localhost"` handle @app { reverse_proxy 127.0.0.1:3000 } respond "not found" 404 } EOF ``` If you use this HTTPS setup, update OAuth callback URLs to: - GitHub: `https://localhost:3001/v1/oauth/github/callback` - Google: `https://localhost:3001/v1/oauth/google/callback` ## Stopping the Stack To stop all services: ```bash docker compose down ``` To stop and remove all data (fresh start): ```bash docker compose down -v ``` ## Troubleshooting Ensure the database is healthy before the server starts: ```bash # Check database status docker compose ps # View database logs docker compose logs remote-db ``` Verify your OAuth callback URLs match exactly for your setup: - HTTP local stack: - GitHub: `http://localhost:3000/v1/oauth/github/callback` - Google: `http://localhost:3000/v1/oauth/google/callback` - HTTPS + Caddy: - GitHub: `https://localhost:3001/v1/oauth/github/callback` - Google: `https://localhost:3001/v1/oauth/google/callback` ElectricSQL requires the `electric_sync` database user, which is created automatically by the Remote Server on first startup. If ElectricSQL fails to connect: 1. Ensure the Remote Server has started successfully and run its migrations 2. Check that `ELECTRIC_ROLE_PASSWORD` is set in your `.env.remote` 3. Restart the stack — ElectricSQL will retry the connection ```bash docker compose --env-file .env.remote -f docker-compose.yml restart electric ``` If port 3000 is in use, you can change it in `docker-compose.yml`: ```yaml ports: - "127.0.0.1:3001:8081" # Change 3001 to your preferred port ``` Update your OAuth callback URLs accordingly. **Problem:** `curl -sk https://relay.localhost:3001/health` returns HTML, and relay/tunnel fails. **Cause:** Caddy routed relay hostnames to the remote app (`:3000`) instead of relay server (`:8082`). **Solution:** 1. Use host-specific routing for `relay.localhost` and `*.relay.localhost` 2. Verify: - `curl -sk https://relay.localhost:3001/health` returns `{"status":"ok"}` - `curl -sk https://localhost:3001/v1/health` returns remote server health JSON ## Next Steps Once you have local development working, you can: - Deploy to Fly.io for production (coming soon) - Deploy with Docker Compose on your own server (coming soon) ================================================ FILE: docs/settings/agent-configurations.mdx ================================================ --- title: "Agent Profiles & Configuration" description: "Create reusable agent configurations with custom settings for planning mode, model selection, and permission levels that you can quickly switch between when creating task attempts" --- Agent profiles are configured using a form-based interface. Select an agent and create or edit configuration variants to customise behaviour. Agent profiles are used throughout Vibe Kanban wherever agents run: onboarding, default settings, attempt creation, and follow-ups. The default configuration appears pre-selected in the agent dropdown in the chat input. ## Common Use Cases | Use Case | Example Configuration | |----------|----------------------| | **Fast iteration** | Disable planning mode, use faster model | | **Complex tasks** | Enable planning mode, use advanced model | | **Autonomous work** | Skip permission prompts (use with caution) | | **Code review** | Enable approvals for all changes | | **Multi-instance** | Enable Claude Code Router for parallel work | ## Key Concepts Understanding these concepts helps you configure agents effectively: When enabled, the agent first creates a detailed plan before writing code. This adds an extra step where you review and approve the approach before implementation begins. Useful for complex tasks where you want to validate the strategy, but adds overhead for simple changes. Agents request permission before performing potentially destructive actions like deleting files, running shell commands, or modifying system configurations. Skipping these prompts (`dangerously_skip_permissions`) allows fully autonomous operation but removes safety guardrails. Distributes requests across multiple Claude Code instances running in parallel. Useful when working on several tasks simultaneously or when you want to reduce wait times by load-balancing across instances. Controls what the agent can access: - **read-only** - Can read files but not modify anything - **workspace-write** - Can modify files within the project directory - **danger-full-access** - Unrestricted file system access Determines when the agent pauses for your confirmation: - **untrusted** - Asks before every action - **on-failure** - Only asks when something goes wrong - **on-request** - Only asks when explicitly requested - **never** - Fully autonomous (no confirmations) Controls how much computational effort the model spends "thinking" before responding. Higher reasoning produces more thorough analysis but takes longer and uses more tokens. Lower reasoning is faster but may miss nuances in complex problems. ## Configuration Access Access agent profiles via **Settings → Agents** in the Workspaces UI. Agent configuration form editor interface with finder-style layout The interface uses a finder-style two-column layout: - **Left column** - Lists all available agents - **Right column** - Lists configurations for the selected agent ## Managing Configurations ### Creating a New Configuration 1. Click the **+** button in the Configurations column Configuration column showing the + button with Create new tooltip 2. Enter a **Configuration Name** (e.g., PRODUCTION, DEVELOPMENT) 3. Optionally select a configuration to **Clone from**, or start blank 4. Click **Create Configuration** Create New Configuration dialog with name field and clone option ### Editing a Configuration 1. Select an agent from the left column 2. Select a configuration from the right column 3. Edit the settings in the form below 4. Click **Save** in the bottom bar ### Deleting a Configuration 1. Select the configuration to delete 2. Click the delete button 3. Confirm deletion You cannot delete the only configuration for an agent. Each agent must have at least one configuration. ### Setting a Default Configuration Click the **Default** badge next to a configuration to make it the default for that agent. The default configuration: - Appears pre-selected in the agent dropdown when creating new attempts - Is used for follow-up messages in workspaces - Shows in the chat input toolbar as the current agent/variant ## Agent Configuration Options Enable planning mode for complex tasks Route requests across multiple Claude Code instances Skip permission prompts (use with caution) [View full CLI reference →](https://docs.anthropic.com/en/docs/claude-code/cli-reference#cli-flags) Choose model variant: `"default"` or `"flash"` Run without confirmations [View full CLI reference →](https://google-gemini.github.io/gemini-cli/) Allow all actions without restrictions (unsafe) [View full documentation →](https://ampcode.com/manual#cli) Execution environment: `"read-only"`, `"workspace-write"`, or `"danger-full-access"` Approval level: `"untrusted"`, `"on-failure"`, `"on-request"`, or `"never"` Reasoning depth: `"low"`, `"medium"`, or `"high"` Summary style: `"auto"`, `"concise"`, `"detailed"`, or `"none"` [View full documentation →](https://github.com/openai/codex) Force execution without confirmation Specify model to use [View full CLI reference →](https://docs.cursor.com/en/cli/reference/parameters) Specify model to use Choose agent type [View full documentation →](https://opencode.ai/docs/cli/#flags-1) Run without confirmations [View full documentation →](https://qwenlm.github.io/qwen-code-docs/en/cli/index) Permission level: `"normal"`, `"low"`, `"medium"`, `"high"`, or `"skip-permissions-unsafe"` Specify which model to use Reasoning depth: `"off"`, `"low"`, `"medium"`, or `"high"` [View full documentation →](https://docs.factory.ai/factory-cli/getting-started/overview) ### Universal Options These options work across multiple agent types: Text appended to the system prompt Options prefixed with "dangerously_" bypass safety confirmations and can perform destructive actions. Use with extreme caution. ## Environment Variables Each agent profile can define environment variables that are injected into the agent process at launch. This is useful for pointing an agent at a non-default API endpoint, supplying API keys, or running Claude Code against third-party providers. Set environment variables in the **Environment Variables** section of the agent configuration form. Agent configuration form showing the Environment Variables section with KEY and value inputs Profile environment variables override any variables already set in your shell. This means you can keep your default Anthropic credentials for normal use and create separate profiles that point to different providers — each profile is fully isolated. ### Using Third-Party Providers with Claude Code Many providers expose an OpenAI-compatible or Anthropic-compatible API. You can use them with Claude Code by creating a dedicated profile with the right environment variables. Create a profile named **GLM** (or any name) and set: | Variable | Value | |----------|-------| | `ANTHROPIC_BASE_URL` | `https://api.z.ai/api/anthropic` | | `ANTHROPIC_AUTH_TOKEN` | Your Z.ai API key | Z.ai automatically maps Claude models to GLM equivalents, so no model override is needed. See the [Z.ai manual setup guide](https://docs.z.ai/devpack/tool/claude#manual-configuration) for details. Create a profile named **OPENROUTER** and set: | Variable | Value | |----------|-------| | `ANTHROPIC_BASE_URL` | `https://openrouter.ai/api` | | `ANTHROPIC_AUTH_TOKEN` | Your OpenRouter API key | | `ANTHROPIC_API_KEY` | `""` (empty string) | Setting `ANTHROPIC_API_KEY` to an empty string prevents Claude Code from falling back to Anthropic's servers. See the [OpenRouter Claude Code integration guide](https://openrouter.ai/docs/guides/guides/claude-code-integration) for details. For any Anthropic-compatible endpoint, refer to your provider's documentation for the required environment variables. Common variables include: | Variable | Purpose | |----------|---------| | `ANTHROPIC_BASE_URL` | Provider's API endpoint | | `ANTHROPIC_AUTH_TOKEN` | Authentication token | | `ANTHROPIC_API_KEY` | API key (set to `""` if using `AUTH_TOKEN` instead) | Your original Anthropic configuration remains untouched. Select the **GLM**, **OPENROUTER**, or custom profile from the agent dropdown when creating a workspace to use the third-party provider, and switch back to the **DEFAULT** profile for normal Anthropic usage. ## Using Agent Configurations Once configured, your agent variants appear in the chat input toolbar, allowing quick switching between configurations. Chat input showing agent variant dropdown with Default, Approvals, Opus, and Plan options Set your default agent and variant in **Settings → General → Default Coding Agent** for consistent behaviour across all attempts. Override defaults when creating attempts by selecting different agent/variant combinations in the attempt dialogue. ## Related Configuration MCP (Model Context Protocol) servers are configured separately under **Settings → MCP Servers** but work alongside agent profiles to extend functionality. Configure MCP servers within Vibe Kanban for your coding agents ================================================ FILE: docs/settings/creating-task-tags.mdx ================================================ --- title: "Creating Tags" description: "Create reusable text snippets that can be quickly inserted into workspace prompts using @mentions. Tags are available globally across all projects." --- ## What are tags? Tags are reusable text snippets that you can quickly insert into workspace prompts by typing `@` followed by the tag name. When you select a tag, its content is automatically inserted at your cursor position. Tags use snake_case naming (no spaces allowed). For example: `bug_report`, `feature_request`, or `code_review_checklist`. ## Managing tags Access tags from **Settings → General → Tags**. Tags are available globally across all projects in your workspace. Tags management interface showing the tag list with names and content Click **Add Tag** to create a new tag. Create tag dialogue showing tag name and content fields - **Tag name**: Use snake_case without spaces (e.g., `acceptance_criteria`) - **Content**: The text that will be inserted when the tag is used Click the edit icon (✏️) next to any tag to modify its name or content. Click the delete icon (🗑️) to remove tags you no longer need. Deleting a tag does not affect existing tasks that already have the tag's content inserted. ## Using tags Insert tags into workspace prompts and follow-up messages using @mention autocomplete. When writing a prompt, type `@` to trigger the autocomplete dropdown. Autocomplete dropdown showing available tags after typing @ symbol Continue typing to filter tags by name, then: - Click on a tag to select it - Use arrow keys to navigate and press Enter to select - Press Escape to close the dropdown The tag's content is automatically inserted at your cursor position, replacing the @query. ## Common use cases Create a `bug_report` tag with standardised bug reporting fields: ``` **Description:** **Steps to reproduce:** 1. 2. 3. **Expected behaviour:** **Actual behaviour:** **Environment:** ``` Create an `acceptance_criteria` tag for feature requirements: ``` **Acceptance criteria:** - [ ] Functionality works as specified - [ ] Unit tests added - [ ] Documentation updated - [ ] Accessibility requirements met - [ ] Performance benchmarks passed ``` Create a `code_review` tag with review checklist items: ``` **Code review checklist:** - [ ] Code follows project conventions - [ ] Tests cover edge cases - [ ] No security vulnerabilities introduced - [ ] Performance impact assessed - [ ] Documentation is clear ``` Tags work in all text fields that support the @mention feature, including workspace prompts and follow-up messages, making it easy to maintain consistency across your tasks. ================================================ FILE: docs/settings/general.mdx ================================================ --- title: "Overview" description: "Configure appearance, default agent, editor, git, and notification preferences" --- The General tab contains application-wide settings for appearance, default agent, editor configuration, git preferences, and notifications. General settings tab showing appearance, default agent, and editor options ## Appearance Customise how the application looks and feels: - **Theme** - Choose between Light and Dark colour schemes - **Language** - Select your preferred language (Browser Default follows your system language) ## Default Coding Agent Default Coding Agent section showing agent and variant dropdowns Set the coding agent that will be used by default when creating new task attempts or follow-ups. - **Agent** - Select your preferred coding agent (e.g., Claude Code, Gemini CLI, Codex). This determines which AI assistant handles your coding tasks. - **Variant** - Choose a configuration variant for the selected agent (e.g., Default, Opus, Approvals). Variants contain different settings like planning mode, model selection, or permission levels. The selected agent and variant appear pre-selected in the attempt creation dialog, saving you time when starting new tasks. You can override the default agent configuration per attempt in the create attempt dialog. The default is just a convenience for your most common workflow. ## Editor Editor settings showing Editor Type dropdown and Remote SSH Host input Configure your code editing experience. ### Selecting Your Editor Choose from various supported editors: - **VS Code** - Microsoft's popular code editor - **Cursor** - VSCode fork with AI-native features - **Windsurf** - VSCode fork optimised for collaborative development - **Zed** - High-performance code editor - **Antigravity** - Google's AI-native code editor - **Neovim**, **Emacs**, **Sublime Text** - Other popular editors - **Custom** - Use a custom shell command ### Custom Editor Example When selecting **Custom**, you can specify any shell command to open files. The command receives the file or directory path as an argument. ```bash # Example: Open with IntelliJ IDEA idea # Example: Open with Sublime Text (custom path) /Applications/Sublime\ Text.app/Contents/SharedSupport/bin/subl # Example: Open with a custom script ~/scripts/open-editor.sh ``` ### Opening Your Editor Open in IDE option in the context bar Once configured, you can open your editor from several places: - **Context bar** - Click your IDE logo icon in the floating context bar to open the workspace - **Command bar** - Press `Cmd/Ctrl + K` and select "Open in IDE" The context bar shows your configured IDE's logo. For more details, see the [Workspaces Interface Guide](/workspaces/interface). ## Remote SSH Configuration When running Vibe Kanban on a remote server (e.g., accessed via Cloudflare tunnel, ngrok, or as a systemctl service), you can configure VSCode-based editors to open projects via SSH instead of assuming localhost. This feature is available for **VS Code**, **Cursor**, and **Windsurf** editors. ### When to Use Remote SSH Enable remote SSH configuration when: - Vibe Kanban runs on a remote server (VPS, cloud instance, etc.) - You access the web UI through a tunnel or reverse proxy - Your code files are on a different machine than your browser - You want your local editor to connect to the remote server via SSH ### Configuration Fields - **Remote SSH Host** (Optional) - The hostname or IP address of your remote server (e.g., `example.com`, `192.168.1.100`, `my-server`). Must be accessible via SSH from your local machine. ### How It Works When remote SSH is configured, clicking "Open in Editor" (or Cursor/Windsurf): 1. Generates a special protocol URL like: `vscode://vscode-remote/ssh-remote+user@host/path/to/project` 2. Opens in your default browser, which launches your local editor 3. Your editor connects to the remote server via SSH 4. The project or task worktree opens in the remote context This works for both project-level and task worktree opening. ### Prerequisites - SSH access configured between your local machine and remote server - SSH keys or credentials set up (no password prompts) - VSCode Remote-SSH extension installed (or equivalent for Cursor/Windsurf) - The remote server path must be accessible via SSH Test your SSH connection first with `ssh user@host` to ensure it works without prompting for passwords. ## Git Git settings showing Branch Prefix and Workspace Directory fields Configure git branch naming and workspace storage preferences. ### Branch Prefix Set a prefix for auto-generated branch names. When you create a new workspace or task, Vibe Kanban automatically creates a git branch for your changes. | Prefix Setting | Example Branch Name | |----------------|---------------------| | `vk` | `vk/1a2b-implement-auth` | | `feature` | `feature/1a2b-implement-auth` | | *(empty)* | `1a2b-implement-auth` | Use a prefix that matches your team's branching conventions (e.g., `feature`, `fix`, or your initials). ### Workspace Directory Specify where Vibe Kanban stores workspace data. Workspaces are created in a `.vibe-kanban-workspaces` subdirectory within this path. - **Default location** - Leave empty to use the system default (typically your home directory) - **Custom location** - Set a specific path if you prefer workspaces on a different drive or directory Changes to Workspace Directory require an app restart. Existing workspaces remain in their original location. ## Notifications Notifications settings showing sound effects and push notifications toggles Configure how Vibe Kanban alerts you about task progress and status changes. - **Sound Effects** - Play audio notifications when tasks complete, need attention, or encounter errors. Useful when working with multiple tasks or when Vibe Kanban runs in a background tab. - **Push Notifications** - Receive browser notifications even when Vibe Kanban isn't in focus. Requires browser permission when first enabled. Enable notifications if you frequently run long-running tasks and want to be alerted when they complete or need your attention. ## Telemetry Enable or disable telemetry data collection to help improve Vibe Kanban. ## Message Input Choose the keyboard shortcut to send messages in the chat input (`Enter` or `⌘/Ctrl + Enter`). ## Tags Tags section showing task tags table with tag names, content, and actions Create reusable text snippets that can be inserted into workspace prompts using `@tag_name`. - **Add Tag +** - Create a new tag - **Tag Name** - The @mention name for the tag - **Content** - The text that will be inserted - **Actions** - Edit or delete existing tags Complete guide to creating and managing tags ================================================ FILE: docs/settings/index.mdx ================================================ --- title: "Settings Overview" description: "Configure application-wide settings including themes, editor, agents, and more" sidebarTitle: "Overview" --- The Settings dialog provides a streamlined, tabbed interface for configuring Vibe Kanban. ## Accessing Settings Workspaces navbar showing the Settings gear icon with tooltip - Click the **Settings** icon () in the Workspaces navbar - Press `Cmd/Ctrl + K` and select **Settings** ## Settings Tabs Settings dialog showing the tabbed interface with General, Projects, Repositories, Organization Settings, Agents, and MCP Servers tabs The settings dialog is organised into the following tabs: | Tab | Description | |-----|-------------| | **General** | Appearance, default agent, editor, git, and notifications | | **Projects** | Project-specific settings | | **Repositories** | Repository scripts (dev server, setup, cleanup) | | **Organization Settings** | Organisation members, invitations, and settings | | **Remote Projects** | Cloud-synced projects across organisations | | **Agents** | Agent profiles and variants | | **MCP Servers** | Model Context Protocol server configuration | ## General Configure appearance, default agent, editor, git preferences, notifications, and tags. Complete guide to general settings including appearance, editor configuration, and more ## Projects & Repositories Configure project-specific settings and repository scripts including dev server, setup, and cleanup commands. Configure project settings and repository scripts ## Organization Settings Manage your Cloud organisations, invite team members, and configure organisation-level settings. Manage members, invitations, and organisation settings ## Remote Projects View and manage all your Cloud-synced projects across organisations. Create, edit, and delete projects from a centralised interface. Manage Cloud projects across all your organisations ## Agents Agents tab showing agent list, configuration variants, and settings options Define and customise agent variants using a form-based interface. The Agents tab uses a finder-style two-column layout: - **Left column (Agents)** - Available coding agents (Claude Code, Codex, Opencode, Cursor Agent, etc.) - **Right column (Configurations)** - Different variants for the selected agent (Default, Opus, Approvals, Plan, etc.) Detailed guide with examples for configuring agent variants ## MCP Servers MCP Servers tab showing agent selection, JSON configuration, and popular servers Configure Model Context Protocol servers to extend coding agent capabilities with custom tools and resources. - **Agent** - Choose which agent to configure MCP servers for (e.g., Claude Code) - **Server Configuration (JSON)** - Edit the MCP server configuration directly in JSON format - **Popular servers** - Click a card to insert a pre-configured MCP server into the JSON Configure MCP servers within Vibe Kanban for your coding agents ## Related Documentation - [Workspaces](/workspaces/index) - The Workspaces UI where settings are accessed ================================================ FILE: docs/settings/mcp-servers.mdx ================================================ --- title: "Connecting MCP Servers" description: "Configure MCP (Model Context Protocol) servers to enhance your coding agents within Vibe Kanban with additional tools and capabilities." --- This page covers configuring MCP servers **within** Vibe Kanban for your coding agents. For connecting external MCP clients to Vibe Kanban's MCP server, see the [Vibe Kanban MCP Server](/integrations/vibe-kanban-mcp-server) guide. ## Overview MCP servers provide additional functionality to coding agents through standardized protocols. You can configure different MCP servers for each coding agent in Vibe Kanban, giving them access to specialized tools like browser automation, access to remote logs, error tracking via Sentry, or documentation from Notion. ## Accessing MCP Server Configuration 1. Navigate to **Settings** in the Workspaces navbar 2. Click on **MCP Servers** tab 3. Select the coding agent you want to configure MCP servers for MCP Server configuration page showing agent selection, JSON configuration, and popular servers ## Popular MCP Servers Vibe Kanban provides one-click installation for popular MCP servers. Click a card to insert a pre-configured MCP server into the JSON configuration. Popular MCP servers including Vibe Kanban, Context7, Playwright, Exa, Chrome DevTools, and Dev Manager ## Adding Custom MCP Servers You can also add your own MCP servers by configuring them manually: Choose the coding agent you want to configure MCP servers for from the Agent dropdown. In the Server Configuration JSON editor, add your custom MCP server configuration. The JSON will show the current configuration for the selected agent, and you can modify it to include additional servers. Example addition: ```json { "mcpServers": { "existing_server": { "command": "npx", "args": ["-y", "some-existing-server"] }, "my_custom_server": { "command": "node", "args": ["/path/to/my-server.js"] } } } ``` After updating the JSON configuration: 1. Click "Save Settings" to apply changes 2. Test the configuration by using the agent with MCP functionality 3. Check agent logs for any connection issues These changes update the global configuration file of the coding agent and will persist even if you stop using Vibe Kanban. ## Best Practices **Server Selection**: Choose MCP servers that complement your coding agent's primary function. For example, use Playwright for agents focused on web development. **Limit MCP Servers**: Avoid adding too many MCP servers to a single coding agent. Too many servers and tools will degrade the effectiveness of coding agents by overwhelming them with options. ## Next Steps - Explore the [Agent Configurations](/settings/agent-configurations) guide for advanced agent setup - Check out [Supported Coding Agents](/supported-coding-agents) for agent-specific features ================================================ FILE: docs/settings/organization-settings.mdx ================================================ --- title: "Organisation Settings" description: "Manage your organisation, members, and invitations" --- The Organisation Settings page lets you manage your Cloud organisations, invite team members, and configure organisation-level settings. Organization Settings page showing members and settings ## Accessing Organisation Settings To open Organisation Settings: 1. Click your **profile icon** in the bottom of the left sidebar 2. Click the **settings icon** () next to your organisation name User menu showing settings icon next to organisation ## Organisation Selection At the top of the settings page, you can: - **Switch organisations** - Use the dropdown to select a different organisation - **Create organisation** - Click **Create Organization +** to create a new organisation Organization switcher dropdown showing multiple organisations If you're a member of multiple organisations, switching here changes which organisation's settings you're viewing and editing. ### Personal Organisations When you first sign up, a personal organisation is created for you with an **Initial Project**. Personal organisations have some restrictions: - Cannot invite additional members - Cannot be deleted - Are only accessible by you To collaborate with others, create a new organisation using the **Create Organization +** button: Create New Organization dialog Enter your organisation name and slug (URL-friendly identifier), then click **Create Organization**. An **Initial Project** is automatically created in your new organisation. ## Pending Invitations This section is only visible to **Admins** and only for non-personal organisations. The Pending Invitations section shows all invitations that have been sent but not yet accepted: - **Email address** - Who was invited - **Status** - Pending acceptance - **Expiry** - Invitations expire after 7 days - **Revoke** - Cancel the invitation if needed ### Revoking an Invitation To revoke a pending invitation: 1. Find the invitation in the list 2. Click the **Revoke** button 3. The invitation link will no longer work ## Members The Members section shows everyone who has access to the organisation: Members section showing member list with Invite Member button | Column | Description | |--------|-------------| | **Name** | Member's display name and avatar | | **Role** | Admin or Member | | **Actions** | Role change, remove (Admin only) | ### Member Roles | Role | Permissions | |------|-------------| | **Admin** | Full access - manage members, invitations, settings, delete organisation | | **Member** | Access projects and issues, cannot manage organisation settings | ### Changing a Member's Role Only Admins can change member roles. 1. Find the member in the list 2. Click the role dropdown next to their name 3. Select the new role (Admin or Member) 4. The change takes effect immediately ### Removing a Member Removing a member revokes their access to all projects in the organisation immediately. 1. Find the member in the list 2. Click the **Remove** button 3. Confirm the removal when prompted ### Inviting New Members To invite someone to your organisation: 1. Click the **Invite Member** button 2. Enter their email address 3. Select their role (Admin or Member) 4. Click **Send Invitation** Invite Member dialog with email and role fields They'll receive an email with a link to join. The invitation expires after 7 days. ## Billing This section is only visible to **Admins** of non-personal organisations. The Billing section provides a link to manage your organisation's billing and subscription in the Vibe Kanban account portal. Click **Manage Billing** to open the billing portal in a new tab where you can: - View your current plan - Update payment methods - View invoices and billing history ## Danger Zone Actions in the Danger Zone are destructive and cannot be undone. Danger Zone section with Delete Organization button ### Deleting an Organisation Only Admins can delete organisations. Personal organisations cannot be deleted. To delete an organisation: 1. Scroll to the **Danger Zone** section 2. Click **Delete** 3. Confirm the deletion when prompted 4. All projects, issues, and data will be permanently deleted Deleting an organisation removes: - All projects in the organisation - All issues and their history - All team member access - All pending invitations This action cannot be undone. ## Related Documentation - [Organisations](/cloud/organizations) - Creating and understanding organisations - [Team Members](/cloud/team-members) - Inviting and managing team members - [Projects](/cloud/projects) - Managing projects within organisations ================================================ FILE: docs/settings/projects-repositories.mdx ================================================ --- title: "Projects & Repositories" description: "Configure project-specific settings and repository scripts in the Settings dialog" --- ## Projects Projects tab showing project selection and project-specific settings The Projects tab allows you to configure settings specific to individual projects. ### Accessing Project Settings 1. Open **Settings** from the Workspaces navbar 2. Select the **Projects** tab 3. Choose a project from the dropdown to view and modify its settings Project settings override global settings where applicable. ## Repositories Repositories tab showing repository selection, general settings, and scripts configuration The Repositories tab allows you to configure scripts that run when a repository is used in workspaces. ### Accessing Repository Settings 1. Open **Settings** from the Workspaces navbar 2. Select the **Repositories** tab 3. Choose a repository from the dropdown to view and modify its settings ### General Settings Configure basic repository information: - **Display Name** - A friendly name for this repository - **Repository Path** - The local path to the repository ## Scripts & Configuration Configure dev server, setup, and cleanup scripts for this repository. These scripts run whenever the repository is used in any workspace, ensuring a consistent development environment. ### Dev Server Script Command to start your development server. This enables the built-in preview browser in Workspaces, allowing you to see your application running as you make changes. **Common examples:** | Framework | Command | |-----------|---------| | Vite | `npm run dev` | | Next.js | `npm run dev` | | Create React App | `npm start` | | Django | `python manage.py runserver` | | Rails | `rails server` | The dev server must output its URL (e.g., `http://localhost:3000`) to stdout for Vibe Kanban to detect and display it in the preview panel. Learn how to use the built-in preview browser with your dev server ### Setup Script Commands that run **before** the coding agent starts working. Use this to prepare the development environment. **Why use a setup script?** - **Install dependencies** - Ensure all packages are installed before the agent modifies code - **Build prerequisites** - Compile shared libraries or generate files the agent needs - **Environment preparation** - Set up databases, pull Docker images, or configure services **Examples:** ```bash # Node.js project npm install # Python project pip install -r requirements.txt # Multiple commands npm install && npm run build:deps # Rust project cargo fetch ``` Setup scripts run in the repository's root directory. They execute once when the workspace starts, not on every agent message. ### Cleanup Script Commands that run **when a workspace closes**. Use this to clean up resources and stop background processes. **Why use a cleanup script?** - **Stop services** - Terminate background processes that might conflict with other workspaces - **Free resources** - Release database connections, Docker containers, or other resources - **Clean temporary files** - Remove build artifacts or cache files - **Format code** - Run formatters to ensure consistent code style after agent changes **Examples:** | Use Case | Command | |----------|---------| | Stop Docker containers | `docker compose down` | | Kill background processes | `pkill -f "node server.js"` | | Clean build artifacts | `rm -rf dist/ .cache/` | | Stop PostgreSQL | `pg_ctl stop -D /usr/local/var/postgres` | | Kill process on port | `lsof -ti:3000 \| xargs kill -9 2>/dev/null` | **Code formatting examples:** | Language/Tool | Command | |---------------|---------| | JavaScript/TypeScript (Prettier) | `npx prettier --write .` | | JavaScript/TypeScript (ESLint) | `npx eslint --fix .` | | Rust (cargo fmt) | `cargo fmt` | | Rust (Clippy) | `cargo clippy --fix --allow-dirty` | | Python (Black) | `black .` | | Python (Ruff) | `ruff check --fix .` | | Go | `go fmt ./...` | **Combining multiple commands:** ```bash # Chain commands with && docker compose down && rm -rf tmp/ # Format code after agent changes npx prettier --write . && npx eslint --fix . # Rust formatting and linting cargo fmt && cargo clippy --fix --allow-dirty # Use || true to ignore failures pkill -f "node server.js" || true rm -rf .cache/ || true ``` Cleanup scripts should be idempotent—safe to run even if the resources don't exist. Use `|| true` to prevent failures when there's nothing to clean up. ## Best Practices Long-running setup scripts delay workspace startup. Install dependencies in setup, but avoid lengthy build processes unless necessary. Scripts run from the repository root. Use relative paths or environment variables rather than hardcoded absolute paths. Add `|| true` to commands that might fail but shouldn't block the workspace: ```bash npm install || true ``` Run your scripts manually in a terminal before configuring them in Vibe Kanban to ensure they work correctly. ## Related Documentation Learn how the dev server integrates with the preview panel Working with multiple repositories in a single workspace ================================================ FILE: docs/settings/remote-projects.mdx ================================================ --- title: "Remote Projects" description: "Manage your Cloud projects across organisations" --- The Remote Projects settings page lets you view and manage all your Cloud-synced projects across your organisations. Remote Projects settings showing organisations and projects ## Accessing Remote Projects Settings To open Remote Projects settings: 1. Open **Settings** (click the gear icon or press the settings shortcut) 2. Select **Remote Projects** from the sidebar You must be signed in to access Remote Projects. If you're not signed in, you'll see a prompt to sign in first. ## Understanding the Layout The Remote Projects page has a two-column layout: | Column | Description | |--------|-------------| | **Organisations** | Lists all organisations you belong to. Personal organisations are marked with a "Personal" badge. | | **Projects** | Shows projects in the selected organisation. Click the **+** button to create a new project. | When you select a project, an edit form appears below where you can modify the project details. ## Viewing Projects Click on an organisation in the left column to view its projects. Projects appear in the right column with their colour indicator. Click any project to select it. ## Creating a Project To create a new project in an organisation: Click on the organisation where you want to create the project. Click the **+** button in the Projects column header. In the dialog that appears: Create Project dialog with name and colour fields - **Project Name** - Enter a name for your project - **Project Colour** - Click the colour swatch to choose a colour Click **Create Project** to create the project. It will appear in the projects list. ## Editing a Project To edit an existing project: Click on the project you want to edit. An edit form appears below the two-column picker. Update the project details: Edit project form with name and colour picker - **Project Name** - Change the display name - **Project Colour** - Select a different colour from the palette A save bar appears at the bottom when you have unsaved changes. Click **Save** to apply your changes, or **Discard** to revert. Changes sync automatically to all team members in the organisation. ## Deleting a Project Deleting a project permanently removes all its issues, comments, and data. This cannot be undone. To delete a project: Select the organisation and locate the project you want to delete. Hover over the project and click the **⋯** (three dots) menu that appears. Project row showing three-dots menu button Select **Delete** from the menu. Dropdown menu with Delete option Confirm the deletion when prompted. The project and all its data will be permanently removed. ## Switching Between Organisations If you belong to multiple organisations, you can quickly switch between them: 1. Click on a different organisation in the left column 2. The projects list updates to show that organisation's projects 3. If you have unsaved changes, you'll be prompted to discard them first ## Troubleshooting Make sure you're signed in. If you just signed up, a personal organisation should have been created automatically. If you still don't see any organisations: 1. Sign out and sign back in 2. Check your internet connection 3. Try refreshing the page Check the following: - You must have an organisation selected - You need to be signed in - Check your internet connection If you're trying to create a project in someone else's organisation, you may not have permission. The save bar appears when you have unsaved changes. Make sure to click **Save** before: - Selecting a different project - Selecting a different organisation - Closing the settings dialog If save fails, check your internet connection and try again. ## Related Documentation - [Projects](/cloud/projects) - Working with projects on the kanban board - [Organisation Settings](/settings/organization-settings) - Managing organisation members - [Organisations](/cloud/organizations) - Understanding organisations ================================================ FILE: docs/supported-coding-agents.mdx ================================================ --- title: "Supported Coding Agents" description: "Complete guide to all coding agents supported by Vibe Kanban, including installation and authentication instructions" --- Vibe Kanban integrates with a variety of coding agents. Each agent requires installation and authentication before use. Select your preferred agent when creating task attempts. ## Available Agents Claude Code CLI OpenAI Codex CLI GitHub Copilot CLI Google Gemini CLI Amp Code Cursor Agent CLI SST OpenCode Factory Droid Claude Code Router - orchestrate multiple models Qwen Code CLI ================================================ FILE: docs/troubleshooting.mdx ================================================ --- title: "Troubleshooting" description: "Common issues and solutions when using Vibe Kanban" --- ## Agent Reports Empty Codebase If your coding agent reports that the codebase is empty when you create a new task, you may have Git's sparse-checkout feature enabled in your repository. **Solution:** Run the following command in the root directory of your repository: ```bash git sparse-checkout disable ``` After disabling sparse-checkout, create a new task and try again. ## Enabling Debug Logs If you need more detailed logs to help debug an issue, you can enable debug-level logging by setting the `RUST_LOG` environment variable. **Usage:** ```bash RUST_LOG=debug npx vibe-kanban ``` This will provide verbose logging output that can help identify the root cause of issues. ## DANGER: Wiping Your Database If you encounter irrecoverable errors and need to completely wipe your Vibe Kanban database, you can delete the application data directory for your operating system. This action is irreversible and will result in the loss of ALL your tasks and settings. Make sure to back up any important information before proceeding. **Delete the following directory based on your OS:** ```bash ~/Library/Application Support/ai.bloop.vibe-kanban/ ``` ```bash ~/.local/share/vibe-kanban/ ``` ``` %APPDATA%\bloop\vibe-kanban\ ``` Typically: `C:\Users\\AppData\Roaming\bloop\vibe-kanban\` After deleting the application data directory, restart Vibe Kanban to reset with an empty database and default settings. ================================================ FILE: docs/workspaces/changes.mdx ================================================ --- title: "Changes Panel" description: "Review code modifications and provide feedback to agents" --- Changes panel showing file tree and side-by-side diff view with code changes The changes panel lets you review all code modifications in your workspace and provide feedback to agents. ## Why Review Changes? Before committing or creating a PR, you should always review what the agent changed: - **Verify correctness** - Does the code do what you asked? - **Check for issues** - Are there bugs, security problems, or style violations? - **Understand the approach** - Learn what the agent did so you can explain it to reviewers - **Provide feedback** - Ask the agent to fix problems before committing **Never blindly trust agent output.** Always review changes before merging. Agents can make mistakes, introduce bugs, or misunderstand requirements. ## Opening Changes - Click the **Changes** toggle () in the navbar - Click the changes icon in the context bar - Use the command bar: `Cmd/Ctrl + K` → "Toggle Changes Panel" ## File Tree The changes panel displays a hierarchical file tree: - **Folders**: Expand/collapse to navigate - **Files**: Click to view the diff - **Search**: Filter files by name - **Expand/Collapse All**: Quick navigation buttons ## Diff Viewer View code changes with syntax highlighting: | Feature | Description | |---------|-------------| | **Additions** | Green highlighting for new lines | | **Deletions** | Red highlighting for removed lines | | **Context** | Surrounding unchanged lines for reference | | **Line numbers** | Original and new line numbers | ## Diff View Modes Toggle between two display modes: | Mode | Description | |------|-------------| | **Unified** | Inline view with changes interleaved | | **Side-by-Side** | Original and modified shown in parallel columns | Switch modes using the diff view toggle in the navbar (when Changes panel is open) or via command bar: `Cmd/Ctrl + K` → "Toggle Diff View Mode". ## Diff Options Customise the diff display: | Option | Description | |--------|-------------| | **Wrap Lines** | Enable/disable line wrapping for long lines | | **Ignore Whitespace** | Show/hide whitespace-only changes | | **Expand All** | Expand all collapsed diff sections | | **Collapse All** | Collapse all diff sections | Access these via the command bar's Diff Options page. ## Giving Feedback with Comments Changes panel showing inline comments on code with Add Review Comment button Add comments directly on code changes to provide feedback to agents. ### Adding Comments 1. Hover over a line in the diff 2. Click the comment icon that appears 3. Write your feedback 4. Submit the comment ### Comment Uses - **Request changes**: Ask the agent to modify specific code - **Ask questions**: Get explanations about implementation choices - **Provide context**: Share additional requirements or constraints - **Approve sections**: Indicate code that looks good ### Agent Response to Comments Chat showing agent responding to review comments by restoring a file When you send a message with review comments, the agent sees your feedback and acts on it. In this example, the agent restores a file after the user requested the change be reverted. Comments are visible to the agent in subsequent messages, helping guide further development. ## GitHub Integration When your workspace is linked to a pull request: ### Viewing GitHub Comments Changes panel showing GitHub icon button with Show GitHub comments tooltip - Click the **GitHub icon** in the changes panel toolbar to toggle GitHub comments - Comments from the PR appear inline with the diff - Badge shows comment count per file GitHub review comment displayed inline in the diff view Only submitted review comments are shown. Pending reviews (not yet submitted on GitHub) won't appear until you submit the review. ### Linking to a PR Workspaces automatically link when: - You create a PR from the workspace - The workspace branch matches an existing PR Review GitHub comments alongside your changes to address PR feedback efficiently. ## Review Workflow Follow this workflow to effectively review and refine agent changes: Let the agent complete its work. The status in the sidebar will change from "Running" to "Idle". Click the Changes icon in the navbar or use `Cmd/Ctrl + K` → "Toggle Changes Panel". Click through each modified file in the file tree. Look for: - **Logic errors** - Does the code do what you intended? - **Missing pieces** - Are there unhandled edge cases? - **Code quality** - Is the code readable and maintainable? - **Security issues** - Any obvious vulnerabilities? For any problems you find, hover over the line and click the comment icon. Write specific feedback: **Good comment:** "This API endpoint should validate the user ID before querying the database to prevent unauthorized access." **Bad comment:** "This is wrong, fix it." Type a message in the chat (e.g., "Please address the review comments") and send it. The agent will see your inline comments and make corrections. After the agent addresses feedback, review the new changes. Repeat until you're satisfied. Once changes look good, create a pull request using the Git panel or command bar. **Be specific in comments.** Instead of "this is wrong", explain what's wrong and ideally suggest a fix. The more context you give, the better the agent can correct the issue. ## Related Documentation - [Browser Testing](/browser-testing) - Test your application with the built-in browser - [Interface Guide](/workspaces/interface) - Overview of workspace panels - [Git Operations](/workspaces/git-operations) - Create PRs and manage branches ================================================ FILE: docs/workspaces/chat-interface.mdx ================================================ --- title: "Chat Interface" description: "Interact with coding agents through the conversation panel" --- Chat interface showing conversation with coding agent The chat interface is your primary way of communicating with coding agents. It supports rich message formatting, file attachments, approval workflows, and multiple conversation sessions. ## Message Types The chat displays different types of messages, each with distinct styling: ### User Messages Your messages appear with an edit option. Click the pencil icon to modify and resend a message. ### Agent Messages Agent responses are rendered with full markdown support, including: - Code blocks with syntax highlighting - Tables and lists - Links and formatted text ### System Messages Informational messages from the system, such as model initialisation or status updates. ### Error Messages Error messages appear in red with a warning icon. Click to expand and see full error details. ## Chat Input ### Writing Messages Chat input formatting toolbar showing bold, italic, underline, strikethrough, and code buttons The chat input supports rich text editing: - **Bold** - `Cmd/Ctrl + B` or click **B** - **Italic** - `Cmd/Ctrl + I` or click **I** - **Underline** - `Cmd/Ctrl + U` or click **U** - **Strikethrough** - Click **S** - **Code** - Click the code button or wrap with backticks ### Attaching Images File picker dialog for attaching images to chat messages You can attach images to your messages: 1. **Click the attachment button** (paperclip icon) in the toolbar 2. **Drag and drop** images directly into the chat 3. Images are uploaded and referenced in your message Attach screenshots to show the agent exactly what you're working on or what issue you're experiencing. ### File Mentions File mentions typeahead showing matching files when typing @ Type `@` to mention files from your repository. The chat provides typeahead suggestions showing: - **Filename** in bold - **Full path** below each file - Matches update as you type Select a file to include it as context for the agent. ## Sending Messages ### Send Actions | Action | Shortcut | Description | |--------|----------|-------------| | **Send** | `Cmd/Ctrl + Enter` | Send message immediately | | **Queue** | Click Queue button | Queue message while agent is running | | **Stop** | Click Stop button | Stop the current agent execution | ### Execution States Chat interface in running state showing Queue and Stop buttons The chat interface shows different states: | State | Description | |-------|-------------| | **Idle** | Ready to send a new message | | **Running** | Agent is actively working | | **Queued** | Your message is queued for when agent finishes | | **Sending** | Message is being sent | When the agent is running, you can queue a follow-up message instead of waiting for it to finish. ## Agent Selection ### Choosing an Agent Select which coding agent to use from the **Agent** dropdown in the chat toolbar. Available agents depend on your configuration. ### Variants Variants dropdown showing Default, Approvals, Opus, and Plan options with Customise button Some agents support different variants or profiles. Use the variant selector to choose: - Different model configurations - Custom system prompts - Specialised behaviours Click **Customise** to configure agent settings. ## Approval Workflow Some agents (especially with planning mode enabled) ask for your approval before making changes. This gives you control over what gets implemented. ### Why Approvals Exist - **Prevent unwanted changes** - Review the plan before code is written - **Catch misunderstandings early** - Ensure the agent understood your request - **Guide the approach** - Steer the agent toward your preferred solution ### Reviewing Plans When the agent needs approval, you'll see an approval card in the chat: 1. **Read the plan summary** - What the agent intends to do 2. **Expand for details** - Click to see the full plan with specific files and changes 3. **Choose an action:** - **Approve** - Agent proceeds with the plan - **Request Changes** - Provide feedback for revision ### Requesting Changes If the plan isn't quite right: 1. Type your feedback in the chat input explaining what should be different 2. Click **Request Changes** 3. The agent revises the plan based on your feedback 4. Review the new plan and approve or request more changes **Be specific in your feedback.** Instead of "that's not right", say "don't modify the database schema - just add the validation to the existing User model." **Approval timeouts:** If you don't respond to an approval request, it may timeout. You'll need to send a new message to restart the task. ### Disabling Approvals If you want the agent to work autonomously without asking for approval: 1. Use an agent variant without planning mode 2. Or configure `dangerously_skip_permissions` in agent settings (use with caution) ## Editing Messages You can edit and resend previous messages: User message showing edit pencil icon in the top right corner Click the pencil icon on any of your messages. The message content appears in an editable text area. Make your changes. Edit mode showing message in editable text area with Cancel and Retry buttons Click **Retry** to resend the modified message. The conversation continues from that point. Click **Cancel** to discard your changes. Editing a message creates a new branch in the conversation. Subsequent messages after the edited one will be replaced. ## Status Indicators ### Token Usage Context gauge showing 13% usage with 27K of 200K tokens used The context gauge shows how much of the agent's context window is used. Understanding this helps you get better results. #### What are Tokens? **Tokens** are how AI models measure text. Roughly: - 1 token ≈ 4 characters or ¾ of a word - A 200K context window can hold about 150,000 words The context window includes everything the agent "remembers": your messages, its responses, code it has read, and file contents. #### Why Token Usage Matters When usage is high: - Agent may "forget" earlier parts of the conversation - Responses may become less accurate - Agent might re-read files it already read - Complex reasoning may suffer #### Usage Levels | Colour | Usage | What to Do | |--------|-------|------------| | Grey | 0-30% | All good, continue working | | Default | 30-60% | Normal usage, keep going | | Orange | 60-80% | Consider starting a new session soon | | Red | 80%+ | Start a new session - agent is near its limit | **Pro tip:** When starting a new session due to high token usage, briefly summarise what was accomplished. The new agent won't know what happened before. ### Tasks Progress Tasks panel showing 5/5 completed with progress bar and list of individual tasks When the agent breaks down work into tasks, a progress indicator appears in the chat toolbar: - **Progress bar** showing completion percentage - **Task count** (e.g., "5/5 completed") - **Individual tasks** with checkmarks when complete Click the indicator to expand the full task list showing each step the agent is working through. ### File Changes The chat shows file modification summaries: - **Green** numbers indicate lines added - **Red** numbers indicate lines removed - Click to view the file in the Changes panel ## Tool Outputs ### Command Execution When the agent runs commands, you'll see: - **Terminal icon** for bash commands - **Exit code** for completed commands - **Fix Script** button if a command fails Click to view full output in the logs panel. ### Search Results Search operations show summarised results. Click to expand and see full search output. ## Review Comments When you add inline comments in the Changes panel: 1. A banner appears showing the comment count 2. Comments are automatically included when you send a message 3. Click **Clear** to remove pending comments ## Keyboard Shortcuts | Shortcut | Action | |----------|--------| | `Cmd/Ctrl + Enter` | Send message (or contextual action) | | `Cmd/Ctrl + B` | Bold text | | `Cmd/Ctrl + I` | Italic text | | `Escape` | Cancel current action | ## Related Documentation - [Slash Commands](/workspaces/slash-commands) - Quick actions with slash commands - [Sessions](/workspaces/sessions) - Managing multiple conversation sessions - [Interface Guide](/workspaces/interface) - Overview of the workspace layout - [Command Bar](/workspaces/command-bar) - Quick actions and shortcuts ================================================ FILE: docs/workspaces/command-bar.mdx ================================================ --- title: "Command Bar" description: "Navigate and control your workspace with keyboard shortcuts" --- Command bar showing search field and list of available commands The command bar is the central hub for navigating and controlling your workspace. Access every action quickly without leaving the keyboard. ## Opening the Command Bar | Platform | Shortcut | |----------|----------| | **Mac** | `Cmd + K` | | **Windows/Linux** | `Ctrl + K` | You can also click the command bar icon in the navbar. ## Available Commands ### Quick Actions Access these from the root command bar page: | Command | Description | |---------|-------------| | New Workspace | Create a new workspace | | Open in IDE | Open the workspace in your configured editor | | Copy Path | Copy the workspace path to clipboard | | Toggle Dev Server | Start or stop the dev server | | Open in Old UI | Switch to the classic kanban interface | | Feedback | Send feedback about Workspaces | | Workspaces Guide | Open the onboarding guide | | Settings | Open application settings | ### Workspace Actions Manage the current workspace: | Command | Description | |---------|-------------| | Start Review | Begin a code review session | | Rename Workspace | Change the workspace name | | Duplicate Workspace | Create a copy of the workspace | | Pin/Unpin Workspace | Toggle pinned status | | Archive/Unarchive | Move to/from archive | | Delete Workspace | Permanently delete the workspace | | Run Setup Script | Execute the repository setup script | | Run Cleanup Script | Execute the repository cleanup script | ### Git Actions Perform git operations: | Command | Description | |---------|-------------| | Create Pull Request | Open PR creation dialog | | Merge | Pull target branch changes into your working branch | | Rebase | Rebase your working branch onto target branch | | Change Target Branch | Switch the merge target | | Push | Push commits to remote (when applicable) | Git commands are context-aware and only appear when they're applicable to the current workspace state. ### View Options Control panel visibility: | Command | Description | |---------|-------------| | Toggle Left Sidebar | Show/hide workspace list | | Toggle Chat Panel | Show/hide conversation | | Toggle Right Sidebar | Show/hide details | | Toggle Changes Panel | Show/hide code changes | | Toggle Logs Panel | Show/hide process logs | | Toggle Preview Panel | Show/hide browser preview | ### Diff Options Customise the diff viewer (available when changes panel is visible): | Command | Description | |---------|-------------| | Toggle Diff View Mode | Switch between unified and side-by-side | | Toggle Wrap Lines | Enable/disable line wrapping | | Toggle Ignore Whitespace | Show/hide whitespace changes | | Expand All Diffs | Expand all collapsed diffs | | Collapse All Diffs | Collapse all expanded diffs | ### Repository Actions For workspaces with multiple repositories, manage individual repos: | Command | Description | |---------|-------------| | Copy Repo Path | Copy specific repository path | | Open Repo in IDE | Open just this repository | | Repository Settings | Configure repository options | | Create PR (repo) | Create PR for specific repo | | Merge (repo) | Merge specific repository | | Rebase (repo) | Rebase specific repository | | Change Target Branch (repo) | Change target for specific repo | ## Keyboard Shortcuts ### Global Shortcuts | Shortcut | Action | |----------|--------| | `Cmd/Ctrl + K` | Open command bar | | `Escape` | Close command bar or dialog | ## Command Bar Navigation The command bar organises commands into pages: 1. **Root** - Quick actions and navigation to other pages 2. **Workspace Actions** - Workspace management commands 3. **Git Actions** - Version control operations 4. **View Options** - Panel visibility toggles 5. **Diff Options** - Diff viewer settings 6. **Repo Actions** - Per-repository commands (multi-repo workspaces) Use the search field to filter commands across all pages, or navigate to specific pages for categorised access. Start typing to search for any command. The command bar uses fuzzy matching, so you don't need to type the exact command name. ## Power User Tips These commands will speed up your workflow significantly: | Shortcut | What It Does | |----------|--------------| | `Cmd/Ctrl + K` → `n` → Enter | New workspace | | `Cmd/Ctrl + K` → `pr` → Enter | Create pull request | | `Cmd/Ctrl + K` → `changes` → Enter | Toggle changes panel | | `Cmd/Ctrl + K` → `preview` → Enter | Toggle preview panel | | `Cmd/Ctrl + K` → `ide` → Enter | Open in your IDE | The command bar uses fuzzy matching - you can type partial words or abbreviations: - `nw` matches "**N**ew **W**orkspace" - `cpr` matches "**C**reate **P**ull **R**equest" - `tog prev` matches "**Tog**gle **Prev**iew Panel" - `reb` matches "**Reb**ase" Use the command bar to quickly show/hide panels without moving your mouse: 1. Press `Cmd/Ctrl + K` 2. Type `toggle` 3. See all toggle options 4. Select the panel you want to show/hide Some commands only appear when relevant: - **Push** only shows when you have unpushed commits - **Rebase** only shows when you're behind target - **Repo Actions** only shows in multi-repo workspaces - **Diff Options** only shows when Changes panel is visible ## Related Documentation - [Interface Guide](/workspaces/interface) - Learn about the workspace layout and panels - [Git Operations](/workspaces/git-operations) - Detailed guide to git commands - [General Settings](/settings/general) - Application settings and preferences ================================================ FILE: docs/workspaces/creating-workspaces.mdx ================================================ --- title: "Creating Workspaces" description: "Learn how to create and configure workspaces for your development tasks" --- Create workspace view showing project selection, repository list, and task input A workspace is your task execution environment where you work with coding agents. Each workspace can include one or more repositories and supports multiple conversation sessions. ## What Happens When You Create a Workspace Understanding what happens behind the scenes helps you work more effectively: Vibe Kanban creates a **git worktree** - a separate working directory with its own branch. This keeps your changes isolated from your main codebase. Your original repository remains untouched. A new branch is created based on your target branch (e.g., `main`). The branch name is auto-generated based on your task (e.g., `vk/abc123-add-login-page`). A coding agent is initialised and ready to receive your instructions. The agent can read files, make changes, and run commands within your workspace. If your repository has setup scripts configured (e.g., `npm install`), they run automatically to prepare the environment. **Where do workspaces live?** Worktrees are created in a `.vibe-kanban-workspaces` directory (configurable in Settings → General → Workspace Directory). Each workspace gets its own folder. ## Creating a New Workspace Click the **+** button at the top of the workspace sidebar, or use the command bar (`Cmd/Ctrl + K`) and select **New Workspace**. Workspace sidebar showing the plus button for creating new workspaces Choose a project from the **Project** dropdown in the right panel. Projects group related repositories together. Project dropdown showing selected project If you haven't created a project yet, see [Creating a New Project](#creating-a-new-project) below. Repository selection showing selected repo with branch dropdown and list of available repositories Select which repositories to include in your workspace: - **Recent repositories** - Click any repo from the list to add it - **Browse repos on disk** - Find repositories not in the recent list - **Create new repo on disk** - Initialise a new git repository You can add multiple repositories to a single workspace if your task spans multiple codebases. Each repository maintains independent git state. For each selected repository, set the **target branch** - this is the branch your changes will eventually be merged into (e.g., `main` or `develop`). Click the branch dropdown next to each repository to change the target branch. **Target branch vs Working branch - what's the difference?** - **Target branch** = Where your changes will eventually be merged (e.g., `main`). You set this. - **Working branch** = Where your changes are made (e.g., `vk/abc123-task-name`). Auto-created from target. Your changes are made on the working branch and don't affect the target until you create and merge a PR. In the chat input at the bottom, describe what you want to accomplish. Be specific about: - What feature or fix you need - Any constraints or requirements - Files or areas of the codebase to focus on Clear, detailed task descriptions help the agent understand your requirements and produce better results. Choose which coding agent to use from the **Agent** dropdown. Available agents depend on your configuration. See [Agent Configurations](/settings/agent-configurations) for details on setting up different agents. Click **Create** to start the workspace. The agent will begin working on your task immediately. ## Creating a New Project If you need to create a new project before setting up your workspace: In the create workspace view, click the **Project** dropdown. Project dropdown showing Create new project option and list of existing projects Click **+ Create new project** at the top of the dropdown list. Create Project dialog with name field Enter a name for your project and click **Create**. Your new project is automatically selected. Continue adding repositories and configuring your workspace. After creating a project, you can configure additional settings like setup scripts, dev server scripts, and cleanup scripts in the project settings. See [Projects & Repositories](/settings/projects-repositories) for more details. ## Workspace Settings Once a workspace is created, you can configure additional settings: ### Working Branch The workspace automatically creates a working branch for your changes. You can view and change this in the **Git** section of the details sidebar. ### Dev Server If your project has a dev server script configured, you can start it using: - The **Play** icon () in the context bar - The command bar: `Cmd/Ctrl + K` → **Start Dev Server** Configure dev server scripts in your project settings. See [Projects & Repositories](/settings/projects-repositories) for setup instructions. ### Workspace Notes Use the **Notes** section in the details sidebar to document important information about the workspace - requirements, decisions, or anything you want to remember. ## Duplicating a Workspace To create a copy of an existing workspace: 1. Open the command bar (`Cmd/Ctrl + K`) 2. Go to **Workspace Actions** 3. Select **Duplicate Workspace** The duplicate includes the same repositories and branch configuration but starts with a fresh conversation. ## Archiving Workspaces When you're done with a workspace, archive it to keep your workspace list clean: **From the navbar:** - Click the **Archive** button () in the top left of the navbar **From the command bar:** 1. Press `Cmd/Ctrl + K` 2. Go to **Workspace Actions** → **Archive** Archived workspaces can be viewed by clicking **View Archive** at the bottom of the sidebar. Use the **Pin** feature to keep important active workspaces at the top of your list. ## Troubleshooting **Possible causes:** - The repository hasn't been added to a project yet - The folder isn't a git repository (no `.git` folder) - The path isn't accessible **Solutions:** 1. Click **Browse repos on disk** to manually locate the repository 2. Ensure the folder contains a `.git` directory 3. Check that Vibe Kanban has permission to access the folder **Possible causes:** - Git worktree creation failed (usually due to uncommitted changes in the original repo) - Branch name conflict - Disk space issues **Solutions:** 1. Commit or stash any uncommitted changes in your original repository 2. Try a different target branch 3. Check available disk space (worktrees require space for a full copy of tracked files) **Possible causes:** - Agent isn't installed or configured - API key issues - Network connectivity problems **Solutions:** 1. Check that your agent (e.g., Claude Code) is installed: run the CLI command manually in terminal 2. Verify API keys are configured in Settings → Agents 3. Check your internet connection **Possible causes:** - Script has errors - Missing dependencies - Wrong working directory **Solutions:** 1. Test the script manually in terminal first 2. Check the Logs panel for error messages 3. Ensure paths in the script are relative to the repository root ## Related Documentation - [Interface Guide](/workspaces/interface) - Learn about the workspace layout and panels - [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories - [Sessions](/workspaces/sessions) - Creating and managing conversation sessions - [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts ================================================ FILE: docs/workspaces/git-operations.mdx ================================================ --- title: "Git Operations" description: "Create pull requests, merge, rebase, and manage branches in Workspaces" --- Git section in the details sidebar showing repository status, branches, and commit information Workspaces provide integrated git operations for managing your code changes. ## Git Basics for Workspaces If you're not familiar with git, here's what you need to know: A **branch** is a separate line of development. Think of it as a copy of your code where you can make changes without affecting the original. In Workspaces: - **Target branch** (e.g., `main`) - The "original" you'll eventually merge back into - **Working branch** (e.g., `vk/abc123-task`) - Your copy where changes happen A **commit** is a saved snapshot of your changes. It's like a save point in a video game - you can always go back to it. In Workspaces, commits happen automatically as the agent works, or you can commit manually through the terminal. A **pull request** is a request to merge your changes from your working branch into the target branch. It: - Shows all your changes in one place - Lets teammates review your code - Runs automated tests (CI) - Provides a history of what was changed and why **Rebasing** updates your working branch with the latest changes from the target branch. It's like saying "pretend I started my work from the current state of main, not the old state." **When to rebase:** - Before creating a PR (to avoid conflicts) - When the target branch has new commits - When prompted by Vibe Kanban **Merging** combines your changes with the target branch. In Workspaces, this typically happens through a PR on GitHub after code review. The "Merge" action in Workspaces pulls the target branch INTO your working branch (the opposite direction of a PR merge). ## Repository Status The Git section in the right sidebar shows: | Information | Description | |-------------|-------------| | **Repository name** | Current repository with status | | **Current branch** | Your working branch | | **Target branch** | Branch you'll merge into | | **Uncommitted changes** | Number of modified files | | **Ahead/Behind** | Commits relative to target branch | | **Conflict indicator** | Shows when merge conflicts exist | ## Creating Pull Requests Create pull request dialog with title, description, and draft mode options Create PRs directly from your workspace. ### Creating a PR **From the Git panel:** 1. Click **Open pull request** in the Git section of the right sidebar 2. Fill in the PR details **From the command bar:** 1. Press `Cmd/Ctrl + K` 2. Select **Create Pull Request** **PR details:** - **Auto-generate PR description with AI** - Let AI write the description based on your changes - **Title** - PR title (auto-filled from task name) - **Description** - Optional details about the changes - **Base Branch** - The branch to merge into - **Create as draft** - Mark as draft PR 4. Click **Create PR** Once created, your PR appears on GitHub with your commits, description, and CI checks running. GitHub pull request page showing the created PR with commits, checks, and comments ### Draft PRs Enable **Draft** mode when: - Work is still in progress - You want early feedback before completion - CI checks should run but reviewers shouldn't merge yet ### Multi-Repo PRs For workspaces with multiple repositories: - Create PRs for each repo separately - Use the Repo Actions in the command bar - Reference related PRs in descriptions Create PRs early to get CI feedback and enable team visibility into your progress. ## Merging Changes Pull the latest changes from the target branch into your working branch. ### Merge Process **From the Git panel:** 1. Click the dropdown arrow next to **Open pull request** 2. Select **Merge** **From the command bar:** 1. Press `Cmd/Ctrl + K` and select **Merge** Target branch changes are merged into your working branch after confirmation. ### Before Merging The workspace checks if you're behind the target branch: - **Up to date**: Merge proceeds normally - **Behind target**: You'll be prompted to rebase first Always ensure CI checks pass and code reviews are complete before merging. ## Rebasing Keep your branch up to date with the target branch. ### Rebase Process **From the Git panel:** 1. Click the target branch dropdown (e.g., **main**) 2. Select **Rebase** from the menu **From the command bar:** 1. Press `Cmd/Ctrl + K` and select **Rebase** Your commits are replayed on top of the target branch after confirmation. ### Handling Conflicts Conflict resolution dialog showing list of conflicting files with resolve and abort options If conflicts occur during rebase: 1. The workspace shows a conflict resolution dialog 2. List of conflicting files is displayed 3. Resolve conflicts in your editor 4. Mark files as resolved 5. Continue or abort the rebase ### When to Rebase - Before creating a pull request - When the target branch has new commits - Before merging to ensure a clean history - When prompted due to being behind target ## Changing Target Branch Switch the branch you're merging into. ### Changing the Target **From the Git panel:** 1. Click the target branch dropdown (e.g., **main**) in the Git section 2. Select a new target branch from the list **From the command bar:** 1. Press `Cmd/Ctrl + K` and select **Change Target Branch** 2. Choose the new target branch The workspace updates to show the new ahead/behind status. ### Use Cases - Switch from `develop` to `main` for release - Target a feature branch instead of main - Correct an incorrectly set target ## Pushing Changes Push your commits to the remote repository. ### When Push is Available The Push command appears when: - You have unpushed commits - A pull request is open for the branch ### Pushing **From the Git panel:** - Click the **Push** button when it appears (shows when you have unpushed commits) **From the command bar:** - Press `Cmd/Ctrl + K` and select **Push** Commits are pushed to the remote repository. Push is contextual - it only appears when there are changes to push and a PR exists. ## Multi-Repository Git Operations For workspaces with multiple repositories, manage each repo independently. ### Per-Repository Actions Access via command bar's **Repo Actions** page: | Action | Description | |--------|-------------| | Create PR | Create PR for specific repository | | Merge | Merge specific repository only | | Rebase | Rebase specific repository | | Change Target | Change target for specific repo | ### Coordinating Changes When working across repos: 1. Make related changes in each repository 2. Create linked PRs referencing each other 3. Merge in the correct order based on dependencies 4. Verify integration after merging ## Conflict Resolution When git conflicts occur: ### Conflict Dialog The workspace displays a conflict resolution dialog showing: - List of conflicting files - Options to resolve or abort ### Resolution Steps 1. Open conflicting files in your editor 2. Resolve the conflicts manually 3. Save the resolved files 4. Return to the workspace 5. Continue the operation ### Aborting If you can't resolve conflicts: - Click **Abort** in the conflict dialog - The operation is cancelled - Your branch returns to its previous state Unresolved conflicts block git operations. Always resolve or abort before continuing work. ## Best Practices ### Branch Hygiene - Rebase regularly to stay current with target - Create PRs early for visibility - Use descriptive branch names - Delete merged branches ### PR Workflow 1. Complete your changes 2. Review diffs in the changes panel 3. Rebase if behind target 4. Create a PR (draft if work continues) 5. Address review feedback 6. Merge when approved ### Multi-Repo Coordination - Plan cross-repo changes upfront - Document dependencies between PRs - Merge in dependency order - Test integration thoroughly ## Troubleshooting **What it means:** You have modified files that haven't been committed. **Solutions:** 1. Let the agent finish its current work (it may be about to commit) 2. Check the Changes panel to see what's modified 3. Commit the changes using the terminal: `git add . && git commit -m "WIP"` 4. Or stash them: `git stash` **What it means:** The target branch (e.g., `main`) has new commits that your working branch doesn't have. **Solution:** Rebase your branch to include the new changes: 1. Open command bar (`Cmd/Ctrl + K`) 2. Select **Rebase** 3. Resolve any conflicts if prompted **What it means:** Your changes and the target branch's changes modify the same lines of code. **Solutions:** 1. Vibe Kanban shows a conflict dialog listing affected files 2. Open each conflicting file in your editor 3. Look for conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) 4. Choose which version to keep (or combine them) 5. Save the file and mark it as resolved 6. Continue the rebase For complex conflicts, you can ask the agent to help resolve them. Start a new message describing the conflict. **Possible causes:** - Not authenticated with GitHub - No changes to create a PR from - Branch doesn't exist on remote **Solutions:** 1. Check GitHub integration in Settings → Integrations 2. Ensure you have committed changes 3. Try pushing the branch first using the terminal **Possible causes:** - No remote configured - Authentication issues - Remote branch is protected **Solutions:** 1. Verify remote is configured: `git remote -v` in terminal 2. Check GitHub authentication in Settings 3. For protected branches, create a PR instead of pushing directly ## Related Documentation - [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories - [Command Bar](/workspaces/command-bar) - Git commands and shortcuts - [GitHub Integration](/integrations/github-integration) - GitHub setup and features ================================================ FILE: docs/workspaces/index.mdx ================================================ --- title: "Workspaces Overview" description: "A redesigned task execution experience with multi-repo support, multiple agent sessions, and an integrated preview browser" sidebarTitle: "Overview" --- Workspaces UI showing the four-panel layout with workspace sidebar, conversation, changes panel, and details sidebar The Workspaces UI is a complete redesign of the task execution experience, providing an IDE-like interface optimised for AI-assisted development workflows. ## What is a Workspace? A **workspace** is an isolated environment for completing a coding task. Think of it as a dedicated "project room" where you: 1. **Work on a specific task** - Each workspace focuses on one goal (e.g., "Add user authentication") 2. **Chat with coding agents** - AI assistants that write and modify code for you 3. **Review changes** - See exactly what code was added, modified, or deleted 4. **Test your application** - Built-in preview browser to see changes in action 5. **Create pull requests** - Submit your changes for review when ready **Why use Workspaces instead of coding manually?** Workspaces let you describe what you want in plain English, and AI coding agents do the implementation. You review the changes, provide feedback, and iterate until the task is complete. It's like having a junior developer who works instantly and never gets tired. ## Key Concepts | Concept | Description | Example | |---------|-------------|---------| | **Workspace** | A task execution environment where you work with agents | "Add user authentication" | | **Repository** | A git repository included in a workspace | `frontend`, `backend` | | **Session** | A conversation thread with a coding agent | Claude Code session for implementing a feature | **How they relate:** - A **workspace** is your working environment for a specific task - A workspace can contain one or more **repositories** (each with independent git state) - Within a workspace, you can have multiple **sessions** to chat with different agents or start fresh conversations For example, you create a workspace for "Add authentication" that includes both `frontend` and `backend` repositories. Within that workspace, you might have one session for implementing the backend API and another session for the frontend UI. ## Key Features Work across multiple repositories within a single workspace. Reference code from one repo while implementing changes in another. Run multiple agent conversations simultaneously. Work around token limits by starting fresh sessions while keeping context. Built-in browser preview without leaving the workspace. Test across desktop, mobile, and custom viewport sizes. Review code modifications with syntax-highlighted diffs. Add inline comments to provide feedback to agents. Built-in terminal for running commands directly in your workspace. New to the Workspaces UI. Notes for each workspace to document important information. New to the Workspaces UI. Central navigation hub for quick actions. Access every workspace action with keyboard shortcuts. ## Common Questions When you create a workspace, Vibe Kanban: 1. Creates a **git worktree** - a separate working directory linked to a new branch 2. Your original repository stays untouched 3. All changes happen in the worktree on a new branch 4. Nothing is pushed to remote until you explicitly create a PR **Your main branch is safe.** The workspace isolates all changes until you're ready to merge. Yes. Each workspace is completely independent: - Create as many workspaces as you need - Each has its own branch, changes, and sessions - Switch between workspaces using the sidebar - Agents in different workspaces don't interfere with each other You have full control: - **Review changes** in the Changes panel before committing - **Add comments** on specific lines to request fixes - **Edit messages** to retry with different instructions - **Start a new session** if the conversation goes off track - **Delete the workspace** if you want to start completely fresh The agent only modifies files in your workspace - it cannot push code or merge without your explicit action. Watch for these indicators: - **Status in sidebar** changes from "Running" to "Idle" - **Chat shows completion** message or asks for next steps - **Changes panel** shows all modifications made If the agent needs your input (approval, clarification), you'll see a "Needs Attention" indicator with a raised hand icon. - **Project** = A container that groups related repositories (configured once in Settings) - **Workspace** = A task execution environment for a specific coding task Think of it this way: A Project is like a team folder that contains your repos. A Workspace is a task you're working on using those repos. ================================================ FILE: docs/workspaces/interface.mdx ================================================ --- title: "Interface Guide" description: "Understanding the Workspaces four-panel layout and navigation" --- Workspaces interface showing the four-panel layout with labels for each section The Workspaces UI uses a flexible four-panel layout designed for efficient AI-assisted development workflows. ## Panel Overview | Panel | Position | Purpose | |-------|----------|---------| | **Workspace Sidebar** | Left edge | List of all workspaces with status indicators | | **Conversation Panel** | Left main | Chat with coding agents, session management | | **Context Panel** | Right main | Changes, logs, or preview (toggleable) | | **Details Sidebar** | Right edge | Git status, terminal, notes | ## Navbar The navbar at the top of the workspace provides quick access to common actions. ### Left Section | Icon | Action | Description | |:----:|--------|-------------| | | Archive Workspace | Move workspace to/from archive | | | Open in Old UI | Switch to the classic interface | ### Right Section - Panel Controls | Icon | Action | Description | |:----:|--------|-------------| | | Toggle Left Sidebar | Show/hide the workspace list | | | Toggle Chat Panel | Show/hide the conversation panel | | | Toggle Changes | Show/hide the changes panel | | | Toggle Logs | Show/hide the logs panel | | | Toggle Preview | Show/hide the preview panel | | | Toggle Right Sidebar | Show/hide the details sidebar | ### Right Section - Utilities | Icon | Action | Description | |:----:|--------|-------------| | | Command Bar | Open the command bar | | | Feedback | Send feedback about Workspaces | | | Workspaces Guide | Open the onboarding guide | | | Settings | Open application settings | When the Changes panel is open, additional diff controls appear for toggling between side-by-side and inline views. ## Workspace Sidebar Workspace sidebar in flat list layout showing workspaces with timestamps and change counts The left sidebar displays all your workspaces at a glance. Switch to accordion layout to group workspaces by status: Workspace sidebar in accordion layout grouped by Needs Attention, Idle, and Running ### Status Indicators Each workspace shows its current state: | Indicator | Meaning | |-----------|---------| | **Running** | Agent is actively processing | | **Idle** | Waiting for input | | **Needs Attention** | Pending approval required (raised hand icon) | | **Pinned** | Workspace pinned to top of list | | **Dev Server** | Blue indicator when dev server is running | | **PR Status** | Badge showing linked pull request status | ### Workspace Actions - **Search**: Filter workspaces by name or branch - **Pin**: Keep important workspaces at the top - **Archive**: Move completed workspaces out of the main list - **Layout toggle**: Switch between flat list and accordion (grouped by status) ### Creating a New Workspace Create Workspace view showing project selection, repositories panel, and task description input Click the **+** button at the top of the sidebar to create a new workspace. The interface switches to create mode: 1. **Select a project** from the Project dropdown in the right sidebar 2. **Add repositories** - click repos from the "Add Repositories" list or browse for repos on disk 3. **Set target branch** - each selected repo shows its target branch (click to change) 4. **Describe your task** in the chat input at the bottom 5. **Select an agent** (e.g., Claude Code) and variant 6. Click **Create** to start the workspace For detailed instructions, see [Creating Workspaces](/workspaces/creating-workspaces) and [Repositories](/workspaces/repositories). ## Conversation Panel Conversation panel showing chat history with coding agent and session switcher The left main panel is where you interact with coding agents. ### Chat Interface - View the full conversation history with the agent - Send messages and follow-up instructions - Rich text support for formatting - Approval workflows for reviewing agent plans - Agent and variant selection See [Chat Interface](/workspaces/chat-interface) for the complete guide. ### Session Dropdown The chat box toolbar includes a session dropdown that lets you: - View all sessions in the workspace - Switch between sessions (shows "Latest" or timestamp) - Create a new session by selecting "New Session" Create multiple sessions to work around conversation token limits or to run different agents in parallel. See [Sessions](/workspaces/sessions) for more details on managing multiple sessions. ### Chat Shortcuts | Shortcut | Action | |----------|--------| | `Cmd/Ctrl + Enter` | Send message | | `Shift + Cmd/Ctrl + Enter` | Alternative send mode | | `Cmd/Ctrl + B` | Bold text | | `Cmd/Ctrl + I` | Italic text | | `Cmd/Ctrl + U` | Underline text | ## Context Panel The right main panel toggles between three views: ### Changes View Changes panel showing file tree and diff viewer with code changes Displays all modified files with inline diffs: - **File tree**: Hierarchical view of changed files - **Search**: Filter files by name - **Diff viewer**: See code changes with syntax highlighting - **Comments**: Add inline comments for agent feedback ### Logs View Logs panel showing process output with tabs for different processes Shows process execution logs: - **Process tabs**: Switch between different running processes - **Log output**: View stdout/stderr in real-time - **Search logs**: Filter log content ### Preview View Preview panel showing built-in browser with dev server output Built-in browser for testing your application: - **Dev server tabs**: Multiple dev servers supported - **Device modes**: Desktop, mobile, custom viewport - **Process logs**: View dev server output Toggle between views using the navbar buttons or the [command bar](/workspaces/command-bar). ## Details Sidebar The right sidebar provides quick access to workspace details. ### Git Section Git section showing repository, branch, and pull request options Always visible, showing: - Current repository and branch - Target branch for merging - Uncommitted changes count - Commits ahead/behind target - Quick access to [git operations](/workspaces/git-operations) See [Repositories](/workspaces/repositories) for details on managing repositories and branches. ### Terminal Section Integrated terminal in the details sidebar showing command output The integrated terminal is a new feature exclusive to the Workspaces UI and is not available in the classic interface. The expandable terminal lets you run commands directly in your workspace: - **Full terminal emulation** powered by xterm.js - **Run any command** - git, npm, build scripts, etc. - **Persistent state** - terminal persists across panel toggles - **Expandable** - collapse when not needed, expand when you need it ### Notes Section Notes section in the details sidebar showing rich text editor with workspace notes Workspace notes are a new feature exclusive to the Workspaces UI and are not available in the classic interface. The expandable notes section lets you document important information: - **Auto-save** - Notes save automatically as you type - **Per-workspace** - Each workspace has its own notes - **Persistent** - Notes are preserved across sessions ## Context Bar Context bar floating toolbar showing quick action buttons A floating toolbar that provides quick access to common actions: | Icon | Action | Description | |:----:|--------|-------------| | *IDE logo* | Open in IDE | Launch workspace in your configured editor (icon shows your IDE) | | | Copy Path | Copy the workspace path to clipboard | | | Toggle Dev Server | Start or stop the development server | | | Toggle Preview | Show or hide the preview panel | | | Toggle Changes | Show or hide the changes panel | The context bar is draggable - position it wherever works best for your workflow. ## Resizing Panels Drag the separators between panels to adjust their proportions. Your layout preferences are saved per workspace. ## Toggling Panels Show or hide panels to focus on what matters: - Use the navbar toggle buttons - Use the [command bar](/workspaces/command-bar) with `Cmd/Ctrl + K` - View Options commands let you toggle any panel Panel states are remembered, so your preferred layout is restored when you return to a workspace. ## Related Documentation - [Creating Workspaces](/workspaces/creating-workspaces) - Step-by-step guide to creating workspaces - [Repositories](/workspaces/repositories) - Managing repositories and branches - [Sessions](/workspaces/sessions) - Working with multiple conversation sessions - [Chat Interface](/workspaces/chat-interface) - Complete guide to the conversation panel - [Command Bar](/workspaces/command-bar) - Master keyboard shortcuts and quick actions - [Browser Testing](/browser-testing) - Built-in browser for testing your application - [Changes Panel](/workspaces/changes) - Review code modifications and provide feedback - [Git Operations](/workspaces/git-operations) - Create PRs, merge, rebase, and manage branches ================================================ FILE: docs/workspaces/managing-workspaces.mdx ================================================ --- title: "Managing Workspaces" description: "Archive, delete, and manage disk space for your workspaces" --- Learn how to organise your workspace list, free up disk space, and understand what happens to your code when you archive or delete workspaces. **Workspace Actions** are available from both the command bar and the workspace sidebar. These actions require an active workspace - they won't appear on the create workspace page. ## Accessing Workspace Actions You can access workspace actions in two ways: **From the sidebar:** Workspace sidebar showing the More actions button on hover Hover over a workspace in the sidebar and click the **...** (More actions) button to see available actions. **From the command bar:** 1. Press `Cmd/Ctrl + K` 2. Go to **Workspace Actions** 3. Select the action you want Workspace Actions menu showing available actions like Rename, Duplicate, Pin, Archive, and Delete ## Pinning Workspaces Keep important workspaces at the top of your list by selecting **Pin Workspace** from the workspace actions. Pinned workspaces appear at the top of the sidebar regardless of their last activity time. ## Archiving Workspaces When you're done with a workspace but might want to return to it later, archive it to keep your workspace list clean. Select **Archive** from the workspace actions, or click the **Archive** button () in the top left of the navbar. Archived workspaces can be viewed by clicking **View Archive** at the bottom of the sidebar. **Archiving preserves everything.** Your conversation history, sessions, notes, and worktree files all remain intact. You can unarchive at any time to continue working. ## Deleting Workspaces To permanently delete a workspace, select **Delete Workspace** from the workspace actions. Deleting a workspace is permanent and cannot be undone. Make sure you've pushed any changes you want to keep before deleting. ### What Gets Deleted When you delete a workspace: | What | Deleted? | Notes | |------|----------|-------| | **Workspace data** | Yes | Conversation history, sessions, notes | | **Git worktree** | Yes | The working directory and its files | | **Git branch** | No | The branch remains in your repository | | **Commits** | No | All commits are preserved in the repository | | **Original repository** | No | Your source repository is never touched | **Your code is safe.** Deleting a workspace removes the worktree copy, but all commits remain in your original repository. If you've pushed to remote or created a PR, your work is preserved. ### Archive vs Delete | Action | Worktree | Conversation | Can Restore? | |--------|----------|--------------|--------------| | **Archive** | Kept on disk | Preserved | Yes - unarchive anytime | | **Delete** | Removed | Deleted | No - permanent | **Use Archive when:** - You might return to this work later - You want to keep the conversation history - You're cleaning up your workspace list but not done with the task **Use Delete when:** - The task is complete and merged - You want to free up disk space - You don't need the conversation history ## Disk Space and Worktree Location Each workspace creates a **git worktree** - a separate working directory with a full copy of your repository's tracked files. Multiple workspaces for large repositories can consume significant disk space. ### Where Worktrees Are Stored Worktrees are stored in a platform-specific directory by default: | Platform | Default Location | |----------|-----------------| | **macOS** | `/var/folders/.../T/vibe-kanban/worktrees` (system temp) | | **Linux** | `/var/tmp/vibe-kanban/worktrees` | | **Windows** | `%TEMP%\vibe-kanban\worktrees` | You can configure a custom location in **Settings → General → Workspace Directory**. When set, worktrees are stored in `{your-path}/.vibe-kanban-workspaces`. ### Checking Disk Usage ```bash du -sh $TMPDIR/vibe-kanban/worktrees ``` ```bash du -sh /var/tmp/vibe-kanban/worktrees ``` ```powershell Get-ChildItem "$env:TEMP\vibe-kanban\worktrees" -Recurse | Measure-Object -Property Length -Sum ``` ### Freeing Up Space To reduce disk usage: 1. **Delete completed workspaces** - Use Delete (not Archive) for tasks that are finished and merged 2. **Review archived workspaces** - Archived workspaces still consume disk space 3. **Configure cleanup scripts** - Add cleanup scripts to remove build artifacts (like `node_modules`) when workspaces close Large directories like `node_modules`, `target/`, or `build/` are copied into each worktree. Consider adding cleanup scripts to remove these when you're done with a workspace. ## Manual Worktree Deletion If you manually delete a worktree directory (e.g., using `rm -rf` in terminal), Vibe Kanban will **automatically recreate it** the next time you open that workspace. The worktree is recreated from the branch's last commit. **Uncommitted changes will be lost.** If you had staged or unstaged changes that weren't committed, they cannot be recovered. Always commit your work before manually deleting worktree directories. ## Orphan Cleanup Vibe Kanban automatically cleans up "orphaned" worktrees on startup - these are worktree directories that no longer have a matching workspace in the database (e.g., if the app crashed during deletion). This cleanup runs automatically, so you don't need to manually manage stale worktree directories. ## Renaming Workspaces To rename a workspace, select **Rename Workspace** from the workspace actions and enter the new name. ## Duplicating Workspaces To create a copy of an existing workspace, select **Duplicate Workspace** from the workspace actions. The duplicate includes the same repositories and branch configuration but starts with a fresh conversation. ## Related Documentation - [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces - [Interface Guide](/workspaces/interface) - Understanding the workspace layout - [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts ================================================ FILE: docs/workspaces/multi-repo-sessions.mdx ================================================ --- title: "Multi-Repo & Sessions" description: "Work with multiple repositories and agent sessions in a single workspace" --- Workspace with multiple repositories showing the repository panel with two repos Workspaces support working across multiple repositories and running multiple agent sessions simultaneously. ## Multi-Repository Support Add multiple repositories to a single workspace to work on cross-repo tasks. ### Adding Repositories Repository selection showing selected repo with branch dropdown and list of available repositories to add When creating a new workspace, add multiple repositories from the create view: 1. Click the **+** button in the workspace sidebar 2. Select your **project** from the dropdown 3. Click repositories from the **Add Repositories** list to include them 4. Set the **target branch** for each selected repository 5. Each repo maintains independent git state ### Working Across Repos With multiple repositories in a workspace: - **Reference code** from one repo while implementing changes in another - **Implement coordinated changes** across multiple codebases - **Manage git operations** independently per repository - **View changes** from all repos in a unified diff view ### Repository Panel Each repository in the workspace shows: | Information | Description | |-------------|-------------| | Repository name | With status indicator | | Current branch | The working branch | | Target branch | Branch to merge into | | Changes count | Uncommitted modifications | | Commits ahead/behind | Relative to target branch | ### Per-Repository Actions Access repository-specific actions via the [command bar](/workspaces/command-bar): - **Open Repo in IDE** - Open just this repository - **Copy Repo Path** - Copy the repository path - **Repository Settings** - Configure repo options - **Git Operations** - PR, merge, rebase per repo Use the command bar's Repo Actions page to quickly switch between repositories when performing git operations. ## Multiple Sessions Session dropdown in chat box toolbar showing multiple sessions Create multiple agent conversation sessions within a single workspace. ### Why Use Multiple Sessions? - **Token limits**: Start fresh sessions when conversations get long - **Parallel work**: Run different tasks or agents simultaneously - **Code review**: Start a dedicated review session - **Experimentation**: Try different approaches in separate sessions ### Creating a New Session 1. Click the session dropdown in the chat box toolbar 2. Select **New Session** from the dropdown 3. Select the agent to use for the new session 4. Start your conversation ### Switching Between Sessions The session dropdown in the chat box toolbar shows all sessions: - **Latest** - the most recent session - Older sessions show their creation timestamp - Click any session to switch to it - Each session maintains its own conversation history ### Session Indicators | Indicator | Meaning | |-----------|---------| | **Running** | Agent is actively processing | | **Idle** | Session waiting for input | | **Agent icon** | Shows which agent is assigned | ### Running Multiple Agents Different sessions can use different coding agents: 1. Create a new session 2. Select a different agent (Claude Code, Gemini CLI, etc.) 3. Both sessions can run in parallel 4. Switch between them to monitor progress Each session operates independently. Changes made by one agent are visible to other sessions through the shared workspace files. ## Example: Full-Stack Feature Here's a practical workflow for implementing a feature across frontend and backend repositories: Create a new workspace, select your project, and add both `frontend` and `backend` repositories. Set target branches to `main` for both. In your initial message, describe the complete feature: ``` Add a user profile page: - Backend: Create GET /api/users/:id endpoint returning user data - Frontend: Add /profile/:id route with a UserProfile component - The frontend should fetch from the backend endpoint ``` The agent reads code from both repositories and makes coordinated changes. It understands the relationship between frontend and backend. Start the dev server (if configured for one repo, you may need to start the other manually via terminal). Test that the frontend correctly calls the backend. Create a PR for each repository: 1. Open command bar (`Cmd/Ctrl + K`) 2. Go to **Repo Actions** 3. Select **Create PR** for the backend repo 4. Repeat for the frontend repo 5. In PR descriptions, reference the related PR Merge backend first (since frontend depends on it), then merge frontend. ## Best Practices ### Multi-Repo Workflows - **Start with a plan**: Describe the cross-repo changes needed upfront - **Coordinate commits**: Ensure related changes are committed together - **Test integration**: Use the preview to verify repos work together - **Create linked PRs**: Reference related PRs across repositories (e.g., "Related to frontend#123") ### Session Management - **One focus per session**: Keep each session focused on a specific goal - **Document in notes**: Use workspace notes to track which session does what - **Limit concurrent sessions**: Too many active sessions can be confusing - **Review before switching**: Check the changes panel when switching sessions **File conflicts:** When multiple sessions modify the same files, the last write wins. Review the Changes panel carefully to ensure nothing was overwritten unexpectedly. ## Related Documentation - [Interface Guide](/workspaces/interface) - Understanding the workspace layout - [Git Operations](/workspaces/git-operations) - Managing git across repositories - [Command Bar](/workspaces/command-bar) - Quick actions for repo management ================================================ FILE: docs/workspaces/repositories.mdx ================================================ --- title: "Repositories" description: "Add and manage repositories within your workspaces" --- Repository selection showing selected repo with branch dropdown and list of available repositories Repositories are the codebases you work with inside a workspace. Each workspace can include one or more repositories, and each repository maintains its own independent git state. ## How Repositories Work in Workspaces When you add a repository to a workspace, Vibe Kanban creates a **git worktree** - a separate working directory linked to your repository. A **git worktree** is a git feature that lets you have multiple working directories for the same repository, each on a different branch. **Why this matters for you:** - Your original repository folder stays exactly as it was - The workspace gets its own folder with its own branch - You can have multiple workspaces working on different features simultaneously - Switching between workspaces doesn't require stashing or committing **Where worktrees are stored:** In the `.vibe-kanban-workspaces` directory (configurable in Settings). Each repository in a workspace has its own: - **Working branch** - The branch where changes are made - **Target branch** - The branch you'll merge into - **Commit history** - Commits made in this workspace - **Staged/unstaged changes** - Independent of other workspaces This means you can have multiple workspaces modifying the same repository on different branches without conflicts. ## Adding Repositories When creating or editing a workspace, you can add repositories from several sources: ### Recent Repositories Recent repositories list showing previously used repos Your recently used repositories appear at the top of the list. Click any repository to add it to the workspace. ### Browse Repos on Disk Select Git Repository dialog with file browser and path input Click **Browse repos on disk** to find repositories that aren't in your recent list. You can: - **Enter path manually** - Type the full path and click **Go** - **Search current directory** - Filter folders by name - **Navigate folders** - Click folder names to browse, use home and up buttons - **Select Current** - Use the current directory as the repository Folders containing git repositories are marked with a **git repo** badge. ### Create New Repo on Disk Create New Repository dialog with name and location fields Click **Create new repo on disk** to initialise a new git repository: 1. Enter a **Name** for the repository 2. Choose a **Location** on disk (click the folder icon to browse) 3. Click **Create Repository** This creates a new folder with an initialised git repository, ready for use in your workspace. ## Target Branches Branch dropdown showing search and list of available branches Each repository in a workspace has a **target branch** - the branch your changes will eventually be merged into (typically `main`, `master`, or `develop`). ### Setting the Target Branch 1. Click the branch dropdown next to the repository name 2. Search or scroll to find the branch you want to target 3. Select the branch - the workspace creates a working branch based on this target The dropdown shows: - **Current** badge - indicates your current branch - **Local branches** - branches on your machine - **Remote branches** - branches from origin (e.g., `origin/main`) Changes you make won't affect the target branch until you create a pull request and merge. ### Changing the Target Branch To change the target branch after workspace creation: **From the Git panel:** 1. Click the target branch dropdown (e.g., **main**) in the Git section of the right sidebar 2. Select a new target branch **From the command bar:** 1. Press `Cmd/Ctrl + K` 2. Select **Change Target Branch** 3. Choose the new target branch Changing the target branch may require rebasing your changes. See [Git Operations](/workspaces/git-operations) if conflicts occur. ## Working Branch When you create a workspace, a **working branch** is automatically created for each repository. This is where your changes are made. The working branch name is based on your task and branch prefix settings (configured in [General Settings](/settings/general)). ### Viewing the Working Branch The current working branch is displayed in the **Git** section of the details sidebar, under "Working Branch". ## Multi-Repository Workspaces Workspace with multiple repositories showing independent git state for each Workspaces can include multiple repositories for tasks that span several codebases. ### When to Use Multiple Repositories - **Monorepo alternatives** - Work across frontend and backend repos simultaneously - **Shared libraries** - Update a library and its consumers together - **Microservices** - Coordinate changes across multiple services ### Independent Git State Each repository in a multi-repo workspace maintains independent git state: - Separate working branches - Separate target branches - Individual commit histories - Independent pull requests ### Cross-Repository Context When working with multiple repositories, the agent can: - Read code from all repositories - Make changes across repositories in a single session - Reference patterns from one repo while implementing in another Use clear task descriptions that specify which repositories should be modified to help the agent understand your intent. ## Repository Actions Access repository-specific actions through the command bar: | Action | Description | |--------|-------------| | **Copy Repo Path** | Copy the repository's local path to clipboard | | **Open Repo in IDE** | Open the repository in your configured editor | | **Repository Settings** | Configure repository-specific settings | | **Create PR** | Create a pull request for this repository | | **Merge** | Merge the target branch into your working branch | | **Rebase** | Rebase your working branch onto the target | | **Push** | Push commits to the remote repository | For multi-repo workspaces, repository actions show which repo they apply to. Select the specific repository when prompted. ## Removing Repositories Repository with X button to remove it from workspace To remove a repository from a workspace, click the **X** button next to the repository name. Removing a repository doesn't delete any code or branches. It only removes the repository from the current workspace. ## Related Documentation - [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces with repositories - [Git Operations](/workspaces/git-operations) - Detailed guide to git commands - [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working across multiple repositories ================================================ FILE: docs/workspaces/sessions.mdx ================================================ --- title: "Sessions" description: "Create and manage multiple conversation sessions within a workspace" --- Session dropdown showing multiple sessions with New Session option Sessions are conversation threads with coding agents within a workspace. Each session maintains its own conversation history, allowing you to work on different aspects of a task or try alternative approaches. ## What is a Session? A **session** is a single conversation with a coding agent. Think of it like a chat thread: - **Each session has its own conversation history** - messages, code changes, and context - **Sessions share the same files** - changes from one session are visible to all sessions - **Sessions are independent processes** - one can be running while another is idle **Key point:** Sessions share files but not conversation context. If Session 1 makes changes, Session 2 can see those file changes but doesn't know what instructions Session 1 received. ## Understanding Token Limits AI models have a **context window** - a limit on how much text they can "remember" in a conversation. When you hit this limit: - The agent may forget earlier parts of the conversation - Responses may become less accurate - You'll see the context gauge turn orange or red **Solution:** Start a new session. The new session gets a fresh context window while still having access to all your files. Watch the **context gauge** in the chat interface. When it shows high usage (orange/red), consider starting a new session. ## When to Create a New Session Context gauge shows high usage - start fresh to give the agent full context capacity. Have agents work on independent parts simultaneously (backend API + frontend UI). Try an alternative solution without losing your original conversation. Use Claude Code for one task, Gemini CLI for another. ## When NOT to Create a New Session - **Continuing related work** - Keep using the same session if the agent needs context from earlier messages - **Providing feedback** - Use the same session to tell the agent what to fix - **Small follow-ups** - "Also add a loading spinner" belongs in the current session New sessions don't inherit conversation history. The new agent won't know what happened in previous sessions unless you explain it again or it reads the file changes. All sessions within a workspace share the same repositories and git state. Changes made by one session are visible to others. ## Creating a New Session Click the session dropdown (showing "Latest" or the session name) in the chat toolbar. Select **+ New Session** from the dropdown menu. Session dropdown showing New Session option and list of existing sessions The new session opens with a fresh conversation. Describe what you want the agent to work on. Give context about what's already been done in other sessions if relevant. The agent doesn't have access to conversations from other sessions. ## Switching Between Sessions To switch to a different session: 1. Click the session dropdown in the chat toolbar 2. Select the session you want to switch to The dropdown shows: - **Session name** - Based on the initial task or auto-generated - **Latest** indicator - Shows which session was most recently active Switching sessions doesn't interrupt any running agent processes. Each session's agent continues working independently. ## Session States Sessions can be in different states: | State | Description | |-------|-------------| | **Running** | Agent is actively processing and making changes | | **Idle** | Waiting for your input | | **Needs Attention** | Agent is waiting for approval or has a question | The workspace sidebar shows the overall workspace state based on its sessions. ## Managing Sessions ### Renaming Sessions Sessions are automatically named based on the initial task description. Currently, session names cannot be manually changed. ### Viewing Session History Each session maintains its complete conversation history. Scroll up in the conversation panel to view earlier messages and agent actions. ### Stopping an Agent If an agent is running in the current session: 1. Click the **Stop** button in the navbar, or 2. Use the keyboard shortcut to stop execution Stopping an agent may leave changes in an incomplete state. Review the changes panel to see what was modified. ## Multiple Agents in Sessions Different sessions can use different agents: 1. Create a new session 2. Select a different agent from the **Agent** dropdown before sending your message 3. Each session remembers which agent it's using Use specialised agents for different tasks - for example, one agent for backend work and another for frontend changes. ## Session Best Practices ### When to Create New Sessions - **Task complexity** - Break complex tasks into smaller sessions - **Token limits** - Start fresh when conversations get long - **Different approaches** - Try alternative solutions without losing progress - **Parallel work** - Have agents work on independent parts simultaneously ### Keeping Sessions Organised - **One focus per session** - Keep each session focused on a specific goal - **Use workspace notes** - Document which session is for what purpose - **Review before switching** - Check the changes panel before switching sessions ## Resolving Conflicts Between Sessions When multiple sessions make changes to the same files: 1. The changes panel shows all modifications across sessions 2. Review changes carefully before committing 3. Use the inline comment feature to mark areas needing attention Git handles most conflicts automatically. For complex conflicts, see [Git Operations](/workspaces/git-operations). ## Related Documentation - [Creating Workspaces](/workspaces/creating-workspaces) - Setting up new workspaces - [Interface Guide](/workspaces/interface) - Understanding the workspace layout - [Multi-Repo & Sessions](/workspaces/multi-repo-sessions) - Working with multiple repositories - [Command Bar](/workspaces/command-bar) - Quick actions and keyboard shortcuts ================================================ FILE: docs/workspaces/slash-commands.mdx ================================================ --- title: "Slash Commands" description: "Use slash commands to run agent commands directly from the chat input" --- Slash command typeahead showing available commands when typing a forward slash Slash commands are shortcuts for common actions within your coding agent. Type `/` followed by a command name to run operations like compacting conversation history, initializing project documentation, or reviewing code changes. ## Using Slash Commands To use a slash command: 1. Type `/` at the beginning of a new line in the chat input 2. A typeahead menu appears showing available commands 3. Continue typing to filter the list, or use arrow keys to navigate 4. Press **Enter** or click to select a command 5. Add any arguments after the command name (some commands accept optional parameters) 6. Send the message as normal Some commands accept optional arguments. For example, with Claude Code or Codex, `/compact focus on the authentication changes` will compact the conversation while emphasising the authentication work in the summary. ## Available Commands by Agent The available slash commands depend on which coding agent you're using. Vibe Kanban discovers and exposes the same commands you'd have in the agent's native CLI, so the experience matches what you're used to. Some built-in commands that require TUI interaction (such as `/model` or `/theme`) are not currently supported in Vibe Kanban. Only non-interactive commands are available. ### Claude Code Claude Code provides built-in commands, user-defined commands, and skills. For full details, see the [built-in commands](https://code.claude.com/docs/en/interactive-mode#built-in-commands) and [skills](https://code.claude.com/docs/en/skills) documentation. | Command | Description | |---------|-------------| | `/compact` | Clear conversation history but keep a summary in context | | `/review` | Review a pull request | | `/security-review` | Complete a security review of pending changes | | `/init` | Initialize a new CLAUDE.md file with codebase documentation | | `/pr-comments` | Get comments from a GitHub pull request | | `/context` | Visualize current context usage | | `/cost` | Show the total cost and duration of the current session | | `/release-notes` | View release notes | Any custom commands or skills you've installed will also appear in the typeahead. ### OpenAI Codex Codex provides built-in commands. For full details, see the [Codex slash commands documentation](https://developers.openai.com/codex/cli/slash-commands/). | Command | Description | |---------|-------------| | `/compact` | Summarize the conversation to free tokens | | `/init` | Generate an AGENTS.md scaffold in the current directory | | `/status` | Display session configuration and token usage | | `/mcp` | List configured MCP tools | ### OpenCode OpenCode provides built-in commands and custom commands. For full details, see the [built-in commands](https://opencode.ai/docs/tui/#commands) and [custom commands](https://opencode.ai/docs/commands) documentation. | Command | Description | |---------|-------------| | `/compact` | Compact the session | | `/commands` | Show all available commands | | `/models` | List available models | | `/agents` | List available agents | | `/status` | Show status information | | `/mcp` | Show MCP server status | ## Command Discovery When you select an agent, Vibe Kanban automatically discovers the available slash commands. You'll see "Discovering commands..." in the typeahead while this happens. Commands are cached per workspace and agent, so subsequent uses are instant. If you add new custom commands to your agent's configuration, switch to a different agent and back, or create a new session to refresh the command list. ## Related Documentation - [Chat Interface](/workspaces/chat-interface) - Complete guide to the conversation panel - [Sessions](/workspaces/sessions) - Managing conversation sessions - [Supported Coding Agents](/supported-coding-agents) - Agent setup and configuration ================================================ FILE: local-build.sh ================================================ #!/bin/bash set -e # Exit on any error # Detect OS and architecture OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) # Map architecture names case "$ARCH" in x86_64) ARCH="x64" ;; arm64|aarch64) ARCH="arm64" ;; *) echo "⚠️ Warning: Unknown architecture $ARCH, using as-is" ;; esac # Map OS names case "$OS" in linux) OS="linux" ;; darwin) OS="macos" ;; *) echo "⚠️ Warning: Unknown OS $OS, using as-is" ;; esac PLATFORM="${OS}-${ARCH}" # Set CARGO_TARGET_DIR if not defined if [ -z "$CARGO_TARGET_DIR" ]; then CARGO_TARGET_DIR="target" fi echo "🔍 Detected platform: $PLATFORM" echo "🔧 Using target directory: $CARGO_TARGET_DIR" # Set API base URL for remote features export VK_SHARED_API_BASE="https://api.vibekanban.com" export VITE_VK_SHARED_API_BASE="https://api.vibekanban.com" echo "🧹 Cleaning previous builds..." rm -rf npx-cli/dist mkdir -p npx-cli/dist/$PLATFORM echo "🔨 Building web app..." (cd packages/local-web && npm run build) echo "🔨 Building Rust binaries..." cargo build --release --manifest-path Cargo.toml cargo build --release --bin vibe-kanban-mcp --manifest-path Cargo.toml echo "📦 Creating distribution package..." # Copy the main binary cp ${CARGO_TARGET_DIR}/release/server vibe-kanban zip -q vibe-kanban.zip vibe-kanban rm -f vibe-kanban mv vibe-kanban.zip npx-cli/dist/$PLATFORM/vibe-kanban.zip # Copy the MCP binary cp ${CARGO_TARGET_DIR}/release/vibe-kanban-mcp vibe-kanban-mcp zip -q vibe-kanban-mcp.zip vibe-kanban-mcp rm -f vibe-kanban-mcp mv vibe-kanban-mcp.zip npx-cli/dist/$PLATFORM/vibe-kanban-mcp.zip # Copy the Review CLI binary cp ${CARGO_TARGET_DIR}/release/review vibe-kanban-review zip -q vibe-kanban-review.zip vibe-kanban-review rm -f vibe-kanban-review mv vibe-kanban-review.zip npx-cli/dist/$PLATFORM/vibe-kanban-review.zip echo "✅ CLI build complete!" echo "📁 Files created:" echo " - npx-cli/dist/$PLATFORM/vibe-kanban.zip" echo " - npx-cli/dist/$PLATFORM/vibe-kanban-mcp.zip" echo " - npx-cli/dist/$PLATFORM/vibe-kanban-review.zip" # Optionally build the Tauri desktop app if [[ "$1" == "--desktop" || "$1" == "--all" ]]; then # Map to Tauri platform naming case "$OS" in macos) TAURI_OS="darwin" ;; linux) TAURI_OS="linux" ;; *) TAURI_OS="$OS" ;; esac case "$ARCH" in arm64) TAURI_ARCH="aarch64" ;; x64) TAURI_ARCH="x86_64" ;; *) TAURI_ARCH="$ARCH" ;; esac TAURI_PLATFORM="${TAURI_OS}-${TAURI_ARCH}" echo "" echo "🖥️ Building Tauri desktop app for $TAURI_PLATFORM..." # Replace the updater endpoint placeholder with a dummy URL for local builds # (CI injects the real R2 URL; locally the updater is non-functional) TAURI_CONF="crates/tauri-app/tauri.conf.json" node -e " const fs = require('fs'); const conf = JSON.parse(fs.readFileSync('$TAURI_CONF', 'utf8')); conf.plugins.updater.endpoints = conf.plugins.updater.endpoints.map(e => e === '__TAURI_UPDATE_ENDPOINT__' ? 'https://localhost/disabled' : e ); fs.writeFileSync('$TAURI_CONF', JSON.stringify(conf, null, 2) + '\n'); " cargo tauri build # Restore tauri.conf.json git checkout -- "$TAURI_CONF" TAURI_DIST="npx-cli/dist/tauri/$TAURI_PLATFORM" mkdir -p "$TAURI_DIST" BUNDLE_DIR="${CARGO_TARGET_DIR}/release/bundle" # Copy updater artifacts (tar.gz bundles or NSIS exe) find "$BUNDLE_DIR" -name "*.app.tar.gz" ! -name "*.sig" -exec cp {} "$TAURI_DIST/" \; 2>/dev/null || true find "$BUNDLE_DIR" -name "*.AppImage.tar.gz" ! -name "*.sig" -exec cp {} "$TAURI_DIST/" \; 2>/dev/null || true find "$BUNDLE_DIR" -name "*-setup.exe" -exec cp {} "$TAURI_DIST/" \; 2>/dev/null || true echo "✅ Desktop app built:" ls -la "$TAURI_DIST/" fi echo "" echo "📦 Installing npx-cli dependencies..." (cd npx-cli && npm ci) echo "" echo "🔨 Building npx-cli TypeScript..." (cd npx-cli && npm run build) echo "" echo "🚀 To test locally, run:" echo " cd npx-cli && node bin/cli.js # browser mode (default)" echo " cd npx-cli && node bin/cli.js --desktop # desktop mode (requires --desktop or --all build flag)" ================================================ FILE: mobile-testing.md ================================================ # Testing on Mobile Devices This guide explains how to access the remote-web frontend from a phone (iPhone/Android) for UI testing. It uses [Tailscale](https://tailscale.com) for stable networking and HTTPS certificates, and [Caddy](https://caddyserver.com) as a reverse proxy — no custom IPs, no random URLs, works on any network. **Time to set up**: ~15 minutes (one-time). After that, it's two commands in two terminals. --- ## Prerequisites ### 1. Install Tailscale on your Mac Download the standalone app from https://tailscale.com/download/mac (recommended). Alternatively, install from the [Mac App Store](https://apps.apple.com/app/tailscale/id1470499037). After installing: 1. Open the Tailscale app 2. Click the Tailscale icon in your menu bar (top-right of screen) 3. Click **Log in** — this opens a browser window to sign in 4. Once signed in, the icon turns active — you're connected > If you already have Tailscale installed, skip this step. ### 2. Install Tailscale on your phone - **iPhone**: [App Store — Tailscale](https://apps.apple.com/app/tailscale/id1470499037) - **Android**: [Play Store — Tailscale](https://play.google.com/store/apps/details?id=com.tailscale.ipn) Sign in with the **same account** you used on your Mac. ### 3. Install Caddy on your Mac ```bash brew install caddy ``` ### 4. Verify both devices are connected Click the Tailscale icon in your Mac menu bar — you should see your Mac listed as connected. You can also verify from the terminal: ```bash tailscale status ``` Both your Mac and phone should appear: ``` 100.x.x.x johns-macbook user@ macOS - 100.x.x.x iphone-john user@ iOS - ``` > If your phone shows "offline", open the Tailscale app on your phone and make sure the toggle is ON. ### 5. Enable MagicDNS and HTTPS Certificates 1. Open https://login.tailscale.com/admin/dns 2. Scroll to the **Nameservers** section — make sure **MagicDNS** is enabled. If you see a "Disable MagicDNS..." button, it's already enabled. 3. Scroll to the bottom of the page to the **"HTTPS Certificates"** section 4. Click **"Enable HTTPS"** if it's not already enabled. If you see a "Disable HTTPS..." button, it's already enabled. > Enabling HTTPS means your machine names and tailnet DNS name will appear on a public certificate ledger. This is how Let's Encrypt works and is normal. --- ## One-Time Setup All commands below auto-detect your Tailscale hostname — no manual copy-pasting needed. ### Step 1 — Save your hostname to your shell profile Run the command for your shell: **zsh** (default on macOS): ```bash echo "export TS_HOSTNAME=$(tailscale status --json | python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))")" >> ~/.zshrc source ~/.zshrc ``` **bash**: ```bash echo "export TS_HOSTNAME=$(tailscale status --json | python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))")" >> ~/.bashrc source ~/.bashrc ``` **fish**: ```bash set -Ux TS_HOSTNAME (tailscale status --json | python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))") ``` Verify it worked: ```bash echo "Your hostname: $TS_HOSTNAME" ``` Verify it resolves: ```bash ping -c 1 $TS_HOSTNAME ``` ### Step 2 — Generate HTTPS certificates ```bash tailscale cert $TS_HOSTNAME ``` This creates `$TS_HOSTNAME.crt` and `$TS_HOSTNAME.key` in the current directory. These are real Let's Encrypt certificates — trusted by all browsers and devices, no extra installation needed on your phone. > Certs expire after 90 days. Re-run `tailscale cert $TS_HOSTNAME` to renew. ### Step 3 — Create the Caddyfile ```bash cat > Caddyfile << EOF ${TS_HOSTNAME}:3001 { tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key reverse_proxy 127.0.0.1:3000 } ${TS_HOSTNAME}:8443 { tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key reverse_proxy 127.0.0.1:8082 } EOF ``` **What this does:** - `https://$TS_HOSTNAME:3001` → proxies to the remote server on localhost:3000 - `https://$TS_HOSTNAME:8443` → proxies to the relay server on localhost:8082 > We use separate ports (3001 for the app, 8443 for the relay) to avoid conflicts with other services on your Tailscale hostname. ### Step 4 — Create a GitHub OAuth app Each developer needs their own GitHub OAuth app so they can sign in from their phone. The app only needs `read:user` and `user:email` scopes — no special permissions required. 1. Go to https://github.com/settings/applications/new 2. Fill in the form: - **Application name**: anything (e.g. `vibe-kanban-mobile-yourname`) - **Homepage URL**: run `echo "https://$TS_HOSTNAME:3001"` and paste the output - **Authorization callback URL**: run `echo "https://$TS_HOSTNAME:3001/v1/oauth/github/callback"` and paste the output 3. Click **Register application** 4. Copy the **Client ID** shown on the next page 5. Click **Generate a new client secret** and copy it immediately (it won't be shown again) 6. Add both values to `crates/remote/.env.remote`: ```bash # Replace with your own values GITHUB_OAUTH_CLIENT_ID=your_client_id GITHUB_OAUTH_CLIENT_SECRET=your_client_secret ``` > `.env.remote` is already in `.gitignore` — your credentials stay local. If the file already has these variables from the shared dev setup, replace them with your own. ## Running There are two modes: **Docker mode** (simple, no hot reload) and **Dev mode** (Vite hot reload for frontend changes). Pick whichever fits your workflow. --- ### Option A — Docker Mode (Simple) The frontend is built inside Docker. No hot reload — you need to restart Docker to see frontend changes. Good for testing backend changes or doing final QA on your phone. **Two terminals:** ```bash # Terminal 1 — Docker stack VITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \ PUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \ pnpm remote:dev # Terminal 2 — Caddy caddy run --config Caddyfile ``` > The first time you run with these env vars, Docker rebuilds the frontend with the Tailscale URLs baked in. This takes a few minutes. Subsequent runs with the same URLs are cached. --- ### Option B — Dev Mode (Vite Hot Reload) The frontend runs outside Docker via Vite, so you get instant hot reload when editing React components. Caddy routes API requests to Docker and everything else to Vite. **Step 1 — Generate `Caddyfile.dev`:** This file can't use shell variables directly, so generate it once (re-run if your hostname changes): ```bash cat > Caddyfile.dev << EOF ${TS_HOSTNAME}:3001 { tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key handle /api/* { reverse_proxy 127.0.0.1:3000 } handle /v1/* { reverse_proxy 127.0.0.1:3000 } handle /shape/* { reverse_proxy 127.0.0.1:3000 } handle { reverse_proxy localhost:3002 { header_up Host localhost:3002 } } } ${TS_HOSTNAME}:8443 { tls ${TS_HOSTNAME}.crt ${TS_HOSTNAME}.key reverse_proxy 127.0.0.1:8082 } EOF ``` **What this routes:** - `/api/*`, `/v1/*`, `/shape/*` → Docker remote server (`:3000`) - Everything else → Vite dev server (`:3002`) with hot reload - `:8443` → Relay server (`:8082`) **Step 2 — Run four terminals:** ```bash # Terminal 1 — Docker backends (no frontend build needed) PUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \ pnpm remote:dev # Terminal 2 — Vite dev server (hot reload) VITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \ pnpm --filter @vibe/remote-web dev # Terminal 3 — Caddy (dev config) caddy run --config Caddyfile.dev # Terminal 4 (optional) — Local desktop client VK_SHARED_API_BASE=https://$TS_HOSTNAME:3001 \ VK_SHARED_RELAY_API_BASE=https://$TS_HOSTNAME:8443 \ pnpm run dev ``` > Vite binds to `localhost:3002`. The `Caddyfile.dev` uses `localhost` (not `127.0.0.1`) to match — this avoids IPv6/IPv4 mismatch issues on macOS. --- ### Accessing from your phone 1. Open the Tailscale app and make sure it's connected (toggle ON) 2. Open Safari (or Chrome) and go to: `https://:3001` (run `echo "https://$TS_HOSTNAME:3001"` if you forgot it) 3. Sign in with GitHub 4. You're in To go back to regular localhost development, just run `pnpm remote:dev` without env vars — no cleanup needed. --- ## Quick Reference **Docker mode (2 terminals):** ```bash # Terminal 1 VITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \ PUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \ pnpm remote:dev # Terminal 2 caddy run --config Caddyfile # On phone echo "https://$TS_HOSTNAME:3001" ``` **Dev mode (4 terminals):** ```bash # Terminal 1 — Docker backends PUBLIC_BASE_URL=https://$TS_HOSTNAME:3001 \ pnpm remote:dev # Terminal 2 — Vite VITE_RELAY_API_BASE_URL=https://$TS_HOSTNAME:8443 \ pnpm --filter @vibe/remote-web dev # Terminal 3 — Caddy caddy run --config Caddyfile.dev # Terminal 4 (optional) — Desktop client VK_SHARED_API_BASE=https://$TS_HOSTNAME:3001 \ VK_SHARED_RELAY_API_BASE=https://$TS_HOSTNAME:8443 \ pnpm run dev # On phone echo "https://$TS_HOSTNAME:3001" ``` --- ## Troubleshooting | Problem | Solution | |---|---| | `$TS_HOSTNAME` is empty | Re-run: `source ~/.zshrc` or restart your terminal | | Phone can't reach the URL | Open Tailscale app on phone → make sure toggle is ON. Run `tailscale status` on Mac to verify both devices are connected | | Phone shows certificate warning | Re-run `tailscale cert $TS_HOSTNAME` — certs may have expired (90-day lifetime) | | `tailscale cert` fails with "does not support getting TLS certs" | Enable HTTPS certificates in Tailscale admin: https://login.tailscale.com/admin/dns → scroll to "HTTPS Certificates" at the bottom → click "Enable HTTPS" | | `tailscale cert` fails with "invalid domain" | Make sure `$TS_HOSTNAME` includes the tailnet name (e.g. `johns-macbook.tail99xyz.ts.net`). Re-run Step 1 | | OAuth redirect fails on phone | Run `echo "https://$TS_HOSTNAME:3001/v1/oauth/github/callback"` and verify it matches what's in GitHub settings | | First build is very slow | Normal — Docker rebuilds the frontend with the new `VITE_RELAY_API_BASE_URL`. Subsequent builds are cached | | Relay features (terminal, logs) don't work on phone | Check that `VITE_RELAY_API_BASE_URL` in the command matches your Caddy relay block (`https://$TS_HOSTNAME:8443`) | | Caddy asks for password | Normal on first run — it installs a local CA certificate. Enter your macOS password | | `caddy run` fails with "address already in use" | Another Caddy instance is running. Kill it: `pkill caddy`, then retry | | `ping $TS_HOSTNAME` doesn't resolve | Enable MagicDNS in Tailscale admin: https://login.tailscale.com/admin/dns | | Dev mode: Vite page loads but API calls fail | Make sure Docker is running (`pnpm remote:dev`) and you're using `Caddyfile.dev` (not `Caddyfile`) | | Dev mode: hot reload doesn't work on phone | Vite HMR uses WebSocket — verify Caddy is proxying to `localhost:3002` (not `127.0.0.1:3002`). Regenerate `Caddyfile.dev` if needed | | Dev mode: blank page or 502 on phone | Vite dev server may not be running. Check Terminal 2 is up with `pnpm --filter @vibe/remote-web dev` | ================================================ FILE: npx-cli/README.md ================================================ # Vibe Kanban > A visual project management tool for developers that integrates with git repositories and coding agents like Claude Code and Amp. ## Quick Start Run vibe kanban instantly without installation: ```bash npx vibe-kanban ``` This will launch the application locally and open it in your browser automatically. Helpful entrypoints: ```bash npx vibe-kanban --help npx vibe-kanban --version npx vibe-kanban review --help npx vibe-kanban mcp --help ``` ## What is Vibe Kanban? Vibe Kanban is a modern project management tool designed specifically for developers. It helps you organize your coding projects with kanban-style task management while providing powerful integrations with git repositories and AI coding agents. ### ✨ Key Features **🗂️ Project Management** - Add git repositories as projects (existing or create new ones) - Automatic git integration and repository validation - Project search functionality across all files - Custom setup and development scripts per project **📋 Task Management** - Create and manage tasks with kanban-style boards - Task status tracking (Todo, In Progress, Done) - Rich task descriptions and notes - Task execution with multiple AI agents **🤖 AI Agent Integration** - **Claude**: Advanced AI coding assistant - **Amp**: Powerful development agent - **Echo**: Simple testing/debugging agent - Create tasks and immediately start agent execution - Follow-up task execution for iterative development **⚡ Development Workflow** - Create isolated git worktrees for each task attempt - View diffs of changes made by agents - Merge successful changes back to main branch - Rebase task branches to stay up-to-date - Manual file editing and deletion - Integrated development server support **🎛️ Developer Tools** - Browse and validate git repositories from filesystem - Open task worktrees in your preferred editor (VS Code, Cursor, Windsurf, IntelliJ, Zed) - Real-time execution monitoring and process control - Stop running processes individually or all at once - Sound notifications for task completion ## How It Works 1. **Add Projects**: Import existing git repositories or create new ones 2. **Create Tasks**: Define what needs to be built or fixed 3. **Execute with AI**: Let coding agents work on your tasks in isolated environments 4. **Review Changes**: See exactly what was modified using git diffs 5. **Merge Results**: Incorporate successful changes into your main codebase ## Core Functionality Vibe Kanban provides a complete project management experience with these key capabilities: **Project Repository Management** - Full CRUD operations for managing coding projects - Automatic git repository detection and validation - Initialize new repositories or import existing ones - Project-wide file search functionality **Task Lifecycle Management** - Create, update, and delete tasks with rich descriptions - Track task progress through customizable status workflows - One-click task creation with immediate AI agent execution - Task attempt tracking with detailed execution history **AI Agent Execution Environment** - Isolated git worktrees for safe code experimentation - Real-time execution monitoring and activity logging - Process management with ability to stop individual or all processes - Support for follow-up executions to iterate on solutions **Code Change Management** - View detailed diffs of all changes made during task execution - Branch status monitoring to track divergence from main - One-click merging of successful changes back to main branch - Automatic rebasing to keep task branches up-to-date - Manual file deletion and cleanup capabilities **Development Integration** - Open task worktrees directly in your preferred code editor - Start and manage development servers for testing changes - Browse local filesystem to add new projects - Health monitoring for service availability ## Configuration Vibe Kanban supports customization through its configuration system: - **Editor Integration**: Choose your preferred code editor - **Sound Notifications**: Customize completion sounds - **Project Defaults**: Set default setup and development scripts ## Technical Architecture - **Backend**: Rust with Axum web framework - **Frontend**: React with TypeScript - **Database**: SQLite for local data storage - **Git Integration**: Native git operations for repository management - **Process Management**: Tokio-based async execution monitoring ## Requirements - Node.js (for npx execution) - Git (for repository operations) - Your preferred code editor (optional, for opening task worktrees) ## Supported Platforms - Linux x64 - Windows x64 - macOS x64 (Intel) - macOS ARM64 (Apple Silicon) ## Use Cases **🔧 Bug Fixes** - Create a task describing the bug - Let an AI agent analyze and fix the issue - Review the proposed changes - Merge if satisfied, or provide follow-up instructions **✨ Feature Development** - Break down features into manageable tasks - Use agents for initial implementation - Iterate with follow-up executions - Test using integrated development servers **🚀 Project Setup** - Bootstrap new projects with AI assistance - Set up development environments - Configure build and deployment scripts **📚 Code Documentation** - Generate documentation for existing code - Create README files and API documentation - Maintain up-to-date project information --- **Ready to supercharge your development workflow?** ```bash npx vibe-kanban ``` _Start managing your projects with the power of AI coding agents today!_ ================================================ FILE: npx-cli/package.json ================================================ { "name": "vibe-kanban", "private": false, "version": "0.1.33", "main": "index.js", "bin": { "vibe-kanban": "bin/cli.js" }, "scripts": { "build": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=bin/cli.js --external:adm-zip --banner:js=\"#!/usr/bin/env node\"", "check": "tsc --noEmit -p tsconfig.json" }, "keywords": [], "author": "bloop", "repository": { "type": "git", "url": "https://github.com/BloopAI/vibe-kanban" }, "engines": { "node": ">=20.19.0" }, "license": "", "description": "NPX wrapper around vibe-kanban and vibe-kanban-mcp", "devDependencies": { "esbuild": "^0.27.2" }, "dependencies": { "adm-zip": "^0.5.16", "cac": "^7.0.0" }, "files": [ "bin", "dist" ] } ================================================ FILE: npx-cli/src/cli.ts ================================================ import { execSync, spawn } from "child_process"; import path from "path"; import fs from "fs"; import { cac } from "cac"; import { ensureBinary, ensureDesktopBundle, BINARY_TAG, CACHE_DIR, DESKTOP_CACHE_DIR, LOCAL_DEV_MODE, LOCAL_DIST_DIR, R2_BASE_URL, getLatestVersion, } from "./download"; import { getTauriPlatform, installAndLaunch, cleanOldDesktopVersions, } from "./desktop"; const CLI_VERSION: string = require("../package.json").version; type RootOptions = { desktop?: boolean; }; // Resolve effective arch for our published 64-bit binaries only. // Any ARM → arm64; anything else → x64. On macOS, handle Rosetta. function getEffectiveArch(): "arm64" | "x64" { const platform = process.platform; const nodeArch = process.arch; if (platform === "darwin") { // If Node itself is arm64, we're natively on Apple silicon if (nodeArch === "arm64") return "arm64"; // Otherwise check for Rosetta translation try { const translated = execSync("sysctl -in sysctl.proc_translated", { encoding: "utf8", }).trim(); if (translated === "1") return "arm64"; } catch { // sysctl key not present → assume true Intel } return "x64"; } // Non-macOS: coerce to broad families we support if (/arm/i.test(nodeArch)) return "arm64"; // On Windows with 32-bit Node (ia32), detect OS arch via env if (platform === "win32") { const pa = process.env.PROCESSOR_ARCHITECTURE || ""; const paw = process.env.PROCESSOR_ARCHITEW6432 || ""; if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64"; } return "x64"; } const platform = process.platform; const arch = getEffectiveArch(); // Map to our build target names function getPlatformDir(): string { if (platform === "linux" && arch === "x64") return "linux-x64"; if (platform === "linux" && arch === "arm64") return "linux-arm64"; if (platform === "win32" && arch === "x64") return "windows-x64"; if (platform === "win32" && arch === "arm64") return "windows-arm64"; if (platform === "darwin" && arch === "x64") return "macos-x64"; if (platform === "darwin" && arch === "arm64") return "macos-arm64"; console.error(`Unsupported platform: ${platform}-${arch}`); console.error("Supported platforms:"); console.error(" - Linux x64"); console.error(" - Linux ARM64"); console.error(" - Windows x64"); console.error(" - Windows ARM64"); console.error(" - macOS x64 (Intel)"); console.error(" - macOS ARM64 (Apple Silicon)"); process.exit(1); } function getBinaryName(base: string): string { return platform === "win32" ? `${base}.exe` : base; } const platformDir = getPlatformDir(); // In local dev mode, extract directly to dist directory; otherwise use global cache const versionCacheDir = LOCAL_DEV_MODE ? path.join(LOCAL_DIST_DIR, platformDir) : path.join(CACHE_DIR, BINARY_TAG, platformDir); // Remove old version directories from the binary cache function cleanOldVersions(): void { try { const entries = fs.readdirSync(CACHE_DIR, { withFileTypes: true, }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== BINARY_TAG) { const oldDir = path.join(CACHE_DIR, entry.name); fs.rmSync(oldDir, { recursive: true, force: true }); } } } catch { // Ignore cleanup errors — not critical } } function showProgress(downloaded: number, total: number): void { const percent = total ? Math.round((downloaded / total) * 100) : 0; const mb = (downloaded / (1024 * 1024)).toFixed(1); const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; process.stderr.write( `\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`, ); } function buildMcpArgs(args: string[]): string[] { return args.length > 0 ? args : ["--mode", "global"]; } async function extractAndRun( baseName: string, launch: (binPath: string) => void, ): Promise { const binName = getBinaryName(baseName); const binPath = path.join(versionCacheDir, binName); const zipPath = path.join(versionCacheDir, `${baseName}.zip`); // Clean old binary if exists try { if (fs.existsSync(binPath)) { fs.unlinkSync(binPath); } } catch (err: unknown) { if (process.env.VIBE_KANBAN_DEBUG) { const msg = err instanceof Error ? err.message : String(err); console.warn(`Warning: Could not delete existing binary: ${msg}`); } } // Download if not cached if (!fs.existsSync(zipPath)) { console.error(`Downloading ${baseName}...`); try { await ensureBinary(platformDir, baseName, showProgress); console.error(""); // newline after progress } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error(`\nDownload failed: ${msg}`); process.exit(1); } } // Extract if (!fs.existsSync(binPath)) { try { const { default: AdmZip } = await import("adm-zip"); const zip = new AdmZip(zipPath); zip.extractAllTo(versionCacheDir, true); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error("Extraction failed:", msg); try { fs.unlinkSync(zipPath); } catch {} process.exit(1); } } if (!fs.existsSync(binPath)) { console.error(`Extracted binary not found at: ${binPath}`); console.error( "This usually indicates a corrupt download. Please try again.", ); process.exit(1); } // Clean up old cached versions only after current version is fully ready if (!LOCAL_DEV_MODE) { cleanOldVersions(); } // Set permissions (non-Windows) if (platform !== "win32") { try { fs.chmodSync(binPath, 0o755); } catch {} } return launch(binPath); } function checkForUpdates(): void { const hasValidR2Url = !R2_BASE_URL.startsWith("__"); if (LOCAL_DEV_MODE || !hasValidR2Url) { return; } getLatestVersion() .then((latest) => { if (latest && latest !== CLI_VERSION) { setTimeout(() => { console.log(`\nUpdate available: ${CLI_VERSION} -> ${latest}`); console.log(`Run: npx vibe-kanban@latest`); }, 2000); } }) .catch(() => {}); } async function runMcp(args: string[]): Promise { await extractAndRun("vibe-kanban-mcp", (bin) => { const proc = spawn(bin, buildMcpArgs(args), { stdio: "inherit", }); proc.on("exit", (c) => process.exit(c || 0)); proc.on("error", (e) => { console.error("MCP server error:", e.message); process.exit(1); }); process.on("SIGINT", () => { proc.kill("SIGINT"); }); process.on("SIGTERM", () => proc.kill("SIGTERM")); }); } async function runReview(args: string[]): Promise { await extractAndRun("vibe-kanban-review", (bin) => { const proc = spawn(bin, args, { stdio: "inherit" }); proc.on("exit", (c) => process.exit(c || 0)); proc.on("error", (e) => { console.error("Review CLI error:", e.message); process.exit(1); }); }); } async function runMain(desktopMode: boolean): Promise { checkForUpdates(); const modeLabel = LOCAL_DEV_MODE ? " (local dev)" : ""; const tauriPlatform = getTauriPlatform(platformDir); // Default: browser mode (headless server + opens browser). // Use --desktop to launch the desktop app instead. if (desktopMode && tauriPlatform) { try { console.log( `Starting vibe-kanban desktop v${CLI_VERSION}${modeLabel}...`, ); const bundleInfo = await ensureDesktopBundle(tauriPlatform, showProgress); console.error(""); // newline after progress // Clean old desktop versions after successful download if (!LOCAL_DEV_MODE) { cleanOldDesktopVersions(DESKTOP_CACHE_DIR, BINARY_TAG); } const exitCode = await installAndLaunch(bundleInfo, platform); process.exit(exitCode); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error(`Desktop app not available: ${msg}`); console.error("Falling back to browser mode..."); } } // Browser mode (default — headless server + opens browser) console.log(`Starting vibe-kanban v${CLI_VERSION}${modeLabel}...`); await extractAndRun("vibe-kanban", (bin) => { execSync(`"${bin}"`, { stdio: "inherit" }); }); } function normalizeArgv(argv: string[]): string[] { const args = argv.slice(2); const mcpFlagIndex = args.indexOf("--mcp"); if (mcpFlagIndex === -1) { return argv; } const normalizedArgs = [ ...args.slice(0, mcpFlagIndex), "mcp", ...args.slice(mcpFlagIndex + 1), ]; return [...argv.slice(0, 2), ...normalizedArgs]; } function runOrExit(task: Promise): void { void task.catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.error("Fatal error:", msg); if (process.env.VIBE_KANBAN_DEBUG && err instanceof Error) { console.error(err.stack); } process.exit(1); }); } async function main(): Promise { fs.mkdirSync(versionCacheDir, { recursive: true }); const cli = cac("vibe-kanban"); cli .command("[...args]", "Launch the local vibe-kanban app") .option("--desktop", "Launch the desktop app instead of browser mode") .allowUnknownOptions() .action((_args: string[], options: RootOptions) => { runOrExit(runMain(Boolean(options.desktop))); }); cli .command("review [...args]", "Run the review CLI") .allowUnknownOptions() .action((args: string[]) => { runOrExit(runReview(args)); }); cli .command("mcp [...args]", "Run the MCP server") .allowUnknownOptions() .action((args: string[]) => { runOrExit(runMcp(args)); }); cli.help(); cli.version(CLI_VERSION); cli.parse(normalizeArgv(process.argv)); } main().catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.error("Fatal error:", msg); if (process.env.VIBE_KANBAN_DEBUG && err instanceof Error) { console.error(err.stack); } process.exit(1); }); ================================================ FILE: npx-cli/src/desktop.ts ================================================ import { execSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import type { DesktopBundleInfo } from './download'; type TauriPlatform = string | null; interface SentinelMeta { type: string; appPath: string; } const PLATFORM_MAP: Record = { 'macos-arm64': 'darwin-aarch64', 'macos-x64': 'darwin-x86_64', 'linux-x64': 'linux-x86_64', 'linux-arm64': 'linux-aarch64', 'windows-x64': 'windows-x86_64', 'windows-arm64': 'windows-aarch64', }; // Map NPX-style platform names to Tauri-style platform names export function getTauriPlatform( npxPlatformDir: string ): TauriPlatform { return PLATFORM_MAP[npxPlatformDir] || null; } // Extract .tar.gz using system tar (available on macOS, Linux, and Windows 10+) function extractTarGz(archivePath: string, destDir: string): void { execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'pipe', }); } function writeSentinel(dir: string, meta: SentinelMeta): void { fs.writeFileSync( path.join(dir, '.installed'), JSON.stringify(meta) ); } function readSentinel(dir: string): SentinelMeta | null { const sentinelPath = path.join(dir, '.installed'); if (!fs.existsSync(sentinelPath)) return null; try { return JSON.parse( fs.readFileSync(sentinelPath, 'utf-8') ) as SentinelMeta; } catch { return null; } } // Try to copy the .app to a destination directory, returning the final path on success function tryCopyApp( srcAppPath: string, destDir: string ): string | null { try { const appName = path.basename(srcAppPath); const destAppPath = path.join(destDir, appName); // Ensure destination directory exists fs.mkdirSync(destDir, { recursive: true }); // Remove existing app at destination if present if (fs.existsSync(destAppPath)) { fs.rmSync(destAppPath, { recursive: true, force: true }); } // Use cp -R for macOS .app bundles (preserves symlinks and metadata) execSync(`cp -R "${srcAppPath}" "${destAppPath}"`, { stdio: 'pipe', }); return destAppPath; } catch { return null; } } // macOS: extract .app.tar.gz, copy to /Applications, remove quarantine, launch with `open` async function installAndLaunchMacOS( bundleInfo: DesktopBundleInfo ): Promise { const { archivePath, dir } = bundleInfo; const sentinel = readSentinel(dir); if (sentinel?.appPath && fs.existsSync(sentinel.appPath)) { return launchMacOSApp(sentinel.appPath); } if (!archivePath || !fs.existsSync(archivePath)) { throw new Error('No archive to extract for macOS desktop app'); } extractTarGz(archivePath, dir); const appName = fs.readdirSync(dir).find((f) => f.endsWith('.app')); if (!appName) { throw new Error( `No .app bundle found in ${dir} after extraction` ); } const extractedAppPath = path.join(dir, appName); // Try to install to /Applications, then ~/Applications, then fall back to cache dir const userApplications = path.join(os.homedir(), 'Applications'); const finalAppPath = tryCopyApp(extractedAppPath, '/Applications') ?? tryCopyApp(extractedAppPath, userApplications) ?? extractedAppPath; // Clean up extracted copy if we successfully copied elsewhere if (finalAppPath !== extractedAppPath) { try { fs.rmSync(extractedAppPath, { recursive: true, force: true }); } catch {} } // Remove quarantine attribute (app is already signed and notarized in CI) try { execSync(`xattr -rd com.apple.quarantine "${finalAppPath}"`, { stdio: 'pipe', }); } catch {} writeSentinel(dir, { type: 'app-tar-gz', appPath: finalAppPath }); return launchMacOSApp(finalAppPath); } function launchMacOSApp(appPath: string): Promise { const appName = path.basename(appPath); console.error(`Launching ${appName}...`); const proc = spawn('open', ['--wait-apps', appPath], { stdio: 'inherit', }); return new Promise((resolve) => { proc.on('exit', (code) => resolve(code || 0)); }); } // Linux: extract AppImage.tar.gz, chmod +x, run async function installAndLaunchLinux( bundleInfo: DesktopBundleInfo ): Promise { const { archivePath, dir } = bundleInfo; const sentinel = readSentinel(dir); if (sentinel?.appPath && fs.existsSync(sentinel.appPath)) { return launchLinuxAppImage(sentinel.appPath); } if (!archivePath || !fs.existsSync(archivePath)) { throw new Error('No archive to extract for Linux desktop app'); } extractTarGz(archivePath, dir); const appImage = fs .readdirSync(dir) .find((f) => f.endsWith('.AppImage')); if (!appImage) { throw new Error(`No .AppImage found in ${dir} after extraction`); } const appImagePath = path.join(dir, appImage); fs.chmodSync(appImagePath, 0o755); writeSentinel(dir, { type: 'appimage-tar-gz', appPath: appImagePath, }); return launchLinuxAppImage(appImagePath); } function launchLinuxAppImage(appImagePath: string): Promise { const appImage = path.basename(appImagePath); console.error(`Launching ${appImage}...`); const proc = spawn(appImagePath, [], { stdio: 'inherit', detached: false, }); return new Promise((resolve) => { proc.on('exit', (code) => resolve(code || 0)); }); } // Windows: run NSIS setup.exe silently, then launch installed app async function installAndLaunchWindows( bundleInfo: DesktopBundleInfo ): Promise { const { dir } = bundleInfo; const sentinel = readSentinel(dir); if (sentinel?.appPath) { const appExe = path.join(sentinel.appPath, 'Vibe Kanban.exe'); if (fs.existsSync(appExe)) { return launchWindowsApp(appExe); } } // Find the NSIS installer const files = fs.readdirSync(dir); const installer = files.find( (f) => f.endsWith('-setup.exe') || (f.endsWith('.exe') && f !== '.installed') ); if (!installer) { throw new Error(`No installer found in ${dir}`); } const installerPath = path.join(dir, installer); const installDir = path.join(dir, 'app'); console.error('Installing Vibe Kanban...'); try { // NSIS supports /S for silent install and /D= for install directory execSync(`"${installerPath}" /S /D="${installDir}"`, { stdio: 'inherit', timeout: 120000, }); } catch { // If silent install fails (e.g. UAC denied), try interactive console.error( 'Silent install failed, launching interactive installer...' ); execSync(`"${installerPath}"`, { stdio: 'inherit' }); // For interactive install, the default location is used const defaultDir = path.join( process.env.LOCALAPPDATA || '', 'vibe-kanban' ); if (fs.existsSync(path.join(defaultDir, 'Vibe Kanban.exe'))) { writeSentinel(dir, { type: 'nsis-exe', appPath: defaultDir, }); return launchWindowsApp( path.join(defaultDir, 'Vibe Kanban.exe') ); } console.error( 'Installation complete. Please launch Vibe Kanban from your Start menu.' ); return 0; } writeSentinel(dir, { type: 'nsis-exe', appPath: installDir }); const appExe = path.join(installDir, 'Vibe Kanban.exe'); if (fs.existsSync(appExe)) { return launchWindowsApp(appExe); } console.error( 'Installation complete. Please launch Vibe Kanban from your Start menu.' ); return 0; } function launchWindowsApp(appExe: string): number { console.error('Launching Vibe Kanban...'); spawn(appExe, [], { detached: true, stdio: 'ignore' }).unref(); return 0; } export async function installAndLaunch( bundleInfo: DesktopBundleInfo, osPlatform: NodeJS.Platform ): Promise { if (osPlatform === 'darwin') { return installAndLaunchMacOS(bundleInfo); } else if (osPlatform === 'linux') { return installAndLaunchLinux(bundleInfo); } else if (osPlatform === 'win32') { return installAndLaunchWindows(bundleInfo); } throw new Error( `Desktop app not supported on platform: ${osPlatform}` ); } export function cleanOldDesktopVersions( desktopBaseDir: string, currentTag: string ): void { try { const entries = fs.readdirSync(desktopBaseDir, { withFileTypes: true, }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== currentTag) { const oldDir = path.join(desktopBaseDir, entry.name); try { fs.rmSync(oldDir, { recursive: true, force: true }); } catch { // Ignore errors (e.g. EBUSY on Windows if app is running) } } } } catch { // Ignore cleanup errors } } ================================================ FILE: npx-cli/src/download.ts ================================================ import https from 'https'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import os from 'os'; // Replaced during npm pack by workflow export const R2_BASE_URL = '__R2_PUBLIC_URL__'; export const BINARY_TAG = '__BINARY_TAG__'; // e.g., v0.0.135-20251215122030 export const CACHE_DIR = path.join(os.homedir(), '.vibe-kanban', 'bin'); // Local development mode: use binaries from npx-cli/dist/ instead of R2 // Only activate if dist/ exists (i.e., running from source after local-build.sh) export const LOCAL_DIST_DIR = path.join(__dirname, '..', 'dist'); export const LOCAL_DEV_MODE = fs.existsSync(LOCAL_DIST_DIR) || process.env.VIBE_KANBAN_LOCAL === '1'; export interface BinaryInfo { sha256: string; size: number; } export interface BinaryManifest { latest?: string; platforms: Record>; } export interface DesktopPlatformInfo { file: string; sha256: string; type: string | null; } export interface DesktopManifest { platforms: Record; } export interface DesktopBundleInfo { archivePath: string | null; dir: string; type: string | null; } type ProgressCallback = (downloaded: number, total: number) => void; function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { https .get(url, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { return fetchJson(res.headers.location!) .then(resolve) .catch(reject); } if (res.statusCode !== 200) { return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); } let data = ''; res.on('data', (chunk: string) => (data += chunk)); res.on('end', () => { try { resolve(JSON.parse(data) as T); } catch { reject(new Error(`Failed to parse JSON from ${url}`)); } }); }) .on('error', reject); }); } function downloadFile( url: string, destPath: string, expectedSha256: string | undefined, onProgress?: ProgressCallback ): Promise { const tempPath = destPath + '.tmp'; return new Promise((resolve, reject) => { const file = fs.createWriteStream(tempPath); const hash = crypto.createHash('sha256'); const cleanup = () => { try { fs.unlinkSync(tempPath); } catch {} }; https .get(url, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { file.close(); cleanup(); return downloadFile( res.headers.location!, destPath, expectedSha256, onProgress ) .then(resolve) .catch(reject); } if (res.statusCode !== 200) { file.close(); cleanup(); return reject( new Error(`HTTP ${res.statusCode} downloading ${url}`) ); } const totalSize = parseInt( res.headers['content-length'] || '0', 10 ); let downloadedSize = 0; res.on('data', (chunk: Buffer) => { downloadedSize += chunk.length; hash.update(chunk); if (onProgress) onProgress(downloadedSize, totalSize); }); res.pipe(file); file.on('finish', () => { file.close(); const actualSha256 = hash.digest('hex'); if (expectedSha256 && actualSha256 !== expectedSha256) { cleanup(); reject( new Error( `Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}` ) ); } else { try { fs.renameSync(tempPath, destPath); resolve(destPath); } catch (err) { cleanup(); reject(err); } } }); }) .on('error', (err) => { file.close(); cleanup(); reject(err); }); }); } export async function ensureBinary( platform: string, binaryName: string, onProgress?: ProgressCallback ): Promise { // In local dev mode, use binaries directly from npx-cli/dist/ if (LOCAL_DEV_MODE) { const localZipPath = path.join( LOCAL_DIST_DIR, platform, `${binaryName}.zip` ); if (fs.existsSync(localZipPath)) { return localZipPath; } throw new Error( `Local binary not found: ${localZipPath}\n` + `Run ./local-build.sh first to build the binaries.` ); } const cacheDir = path.join(CACHE_DIR, BINARY_TAG, platform); const zipPath = path.join(cacheDir, `${binaryName}.zip`); if (fs.existsSync(zipPath)) return zipPath; fs.mkdirSync(cacheDir, { recursive: true }); const manifest = await fetchJson( `${R2_BASE_URL}/binaries/${BINARY_TAG}/manifest.json` ); const binaryInfo = manifest.platforms?.[platform]?.[binaryName]; if (!binaryInfo) { throw new Error( `Binary ${binaryName} not available for ${platform}` ); } const url = `${R2_BASE_URL}/binaries/${BINARY_TAG}/${platform}/${binaryName}.zip`; await downloadFile(url, zipPath, binaryInfo.sha256, onProgress); return zipPath; } export const DESKTOP_CACHE_DIR = path.join( os.homedir(), '.vibe-kanban', 'desktop' ); export async function ensureDesktopBundle( tauriPlatform: string, onProgress?: ProgressCallback ): Promise { // In local dev mode, use Tauri bundle from npx-cli/dist/tauri// if (LOCAL_DEV_MODE) { const localDir = path.join(LOCAL_DIST_DIR, 'tauri', tauriPlatform); if (fs.existsSync(localDir)) { const files = fs.readdirSync(localDir); const archive = files.find( (f) => f.endsWith('.tar.gz') || f.endsWith('-setup.exe') ); return { dir: localDir, archivePath: archive ? path.join(localDir, archive) : null, type: null, }; } throw new Error( `Local desktop bundle not found: ${localDir}\n` + `Run './local-build.sh --desktop' first to build the Tauri app.` ); } const cacheDir = path.join( DESKTOP_CACHE_DIR, BINARY_TAG, tauriPlatform ); // Check if already installed (sentinel file from previous run) const sentinelPath = path.join(cacheDir, '.installed'); if (fs.existsSync(sentinelPath)) { return { dir: cacheDir, archivePath: null, type: null }; } fs.mkdirSync(cacheDir, { recursive: true }); // Fetch the desktop manifest const manifest = await fetchJson( `${R2_BASE_URL}/binaries/${BINARY_TAG}/tauri/desktop-manifest.json` ); const platformInfo = manifest.platforms?.[tauriPlatform]; if (!platformInfo) { throw new Error( `Desktop app not available for platform: ${tauriPlatform}` ); } const destPath = path.join(cacheDir, platformInfo.file); // Skip download if file already exists (e.g. previous failed install) if (!fs.existsSync(destPath)) { const url = `${R2_BASE_URL}/binaries/${BINARY_TAG}/tauri/${tauriPlatform}/${platformInfo.file}`; await downloadFile(url, destPath, platformInfo.sha256, onProgress); } return { archivePath: destPath, dir: cacheDir, type: platformInfo.type, }; } export async function getLatestVersion(): Promise { const manifest = await fetchJson( `${R2_BASE_URL}/binaries/manifest.json` ); return manifest.latest; } ================================================ FILE: npx-cli/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src"] } ================================================ FILE: package.json ================================================ { "name": "vibe-kanban", "version": "0.1.33", "private": true, "bin": { "vibe-kanban": "npx-cli/bin/cli.js" }, "files": [ "npx-cli/bin/cli.js", "npx-cli/dist/**" ], "scripts": { "lint": "pnpm run local-web:lint && pnpm run ui:lint && pnpm run backend:lint && node scripts/check-unused-i18n-keys.mjs", "format": "pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format", "check": "pnpm run local-web:legacy-path-guard && pnpm run local-web:check && pnpm run remote-web:check && pnpm run web-core:check && pnpm run ui:check && pnpm run backend:check", "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \"pnpm run backend:dev:watch\" \"pnpm run local-web:dev\"", "test:npm": "./test-npm-package.sh", "local-web:lint": "pnpm --filter @vibe/local-web run lint", "local-web:legacy-path-guard": "./scripts/check-legacy-frontend-paths.sh", "web-core:format": "pnpm --filter @vibe/web-core run format", "web-core:check": "pnpm --filter @vibe/web-core run check", "local-web:dev": "cd packages/local-web && pnpm run dev -- --port ${FRONTEND_PORT:-3000}", "local-web:check": "pnpm --filter @vibe/local-web run check", "remote-web:check": "pnpm --filter @vibe/remote-web run check", "local-web:format": "pnpm --filter @vibe/local-web run format", "remote-web:format": "pnpm --filter @vibe/remote-web run format", "ui:lint": "pnpm --filter @vibe/ui run lint", "ui:check": "pnpm --filter @vibe/ui run check", "backend:lint": "cargo clippy --workspace --all-targets --features qa-mode -- -D warnings && cargo clippy --manifest-path crates/remote/Cargo.toml --all-targets -- -D warnings", "backend:format": "cargo fmt --all && cargo fmt --all --manifest-path crates/remote/Cargo.toml", "backend:dev": "BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) pnpm run backend:dev:watch", "backend:check": "cargo check --workspace && cargo check --manifest-path crates/remote/Cargo.toml", "backend:dev:watch": "DISABLE_WORKTREE_CLEANUP=1 RUST_LOG=debug cargo watch -w crates -x 'run --bin server'", "dev:qa": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \"pnpm run backend:dev:watch:qa\" \"pnpm run local-web:dev\"", "generate-types": "cargo run --bin generate_types", "generate-types:check": "cargo run --bin generate_types -- --check", "remote:generate-types": "cargo run --manifest-path crates/remote/Cargo.toml --bin remote-generate-types", "remote:generate-types:check": "cargo run --manifest-path crates/remote/Cargo.toml --bin remote-generate-types -- --check", "prepare-db": "node scripts/prepare-db.js", "prepare-db:check": "node scripts/prepare-db.js --check", "build:bippy-bundle": "node scripts/build-bippy-bundle.mjs", "build:npx": "bash ./local-build.sh", "build:npx-cli": "cd npx-cli && npm ci && npm run build", "check:npx-cli": "tsc --noEmit -p npx-cli/tsconfig.json", "prepack": "pnpm run build:npx && pnpm run build:npx-cli", "remote:dev": "cd crates/remote && docker compose --env-file .env.remote up --build ; docker compose --env-file .env.remote down -v", "remote:dev:clean": "cd crates/remote && docker compose --env-file .env.remote down -v", "remote:prepare-db": "cd crates/remote && bash scripts/prepare-db.sh", "remote:prepare-db:check": "cd crates/remote && bash scripts/prepare-db.sh --check", "tauri:dev": "set -a && [ -f .env ] && . ./.env && set +a && export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export DISABLE_WORKTREE_CLEANUP=1 && export RUST_LOG=debug && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && cd crates/tauri-app && cargo tauri dev", "tauri:build": "cd crates/tauri-app && cargo tauri build" }, "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/node": "^20.0.0", "bippy": "0.5.28", "concurrently": "^8.2.2", "esbuild": "^0.27.2", "jwt-decode": "^4.0.0", "typescript": "^5.7.0", "vite": "^7.3.1" }, "engines": { "node": ">=20", "pnpm": ">=8" }, "packageManager": "pnpm@10.13.1" } ================================================ FILE: packages/local-web/.eslintrc.cjs ================================================ const path = require('path'); const i18nCheck = process.env.LINT_I18N === 'true'; // Presentational components - these must be stateless and receive all data via props const presentationalComponentPatterns = [ 'src/components/ui-new/views/**/*.tsx', 'src/components/ui-new/primitives/**/*.tsx', ]; const baseRestrictedImportPaths = [ { name: '@ebay/nice-modal-react', importNames: ['default'], message: 'Do not import NiceModal directly. Use typed dialog APIs from migrated shared modules.', }, { name: '@/lib/modals', importNames: ['showModal', 'hideModal', 'removeModal'], message: 'Do not import showModal/hideModal/removeModal. Use DialogName.show(props) and DialogName.hide() instead.', }, { name: '@vibe/ui', message: 'Do not import from @vibe/ui root. Use @vibe/ui/components/* subpaths.', }, ]; // All legacy directory import patterns. const allLegacyPatterns = [ '@/components', '@/components/**', '@/constants', '@/constants/**', '@/contexts', '@/contexts/**', '@/hooks', '@/hooks/**', '@/keyboard', '@/keyboard/**', '@/lib', '@/lib/**', '@/types', '@/types/**', '@/utils', '@/utils/**', ]; const legacyDirectoryFilePatterns = [ 'src/components/**/*.{ts,tsx}', 'src/constants/**/*.{ts,tsx}', 'src/contexts/**/*.{ts,tsx}', 'src/hooks/**/*.{ts,tsx}', 'src/keyboard/**/*.{ts,tsx}', 'src/lib/**/*.{ts,tsx}', 'src/types/**/*.{ts,tsx}', 'src/utils/**/*.{ts,tsx}', ]; // Legacy directories that should not be imported from proper FSD layers. // These are pending migration into shared/, features/, widgets/, or pages/. const legacyBanGroup = { group: allLegacyPatterns, message: 'Do not import from legacy directories. Use the equivalent shared/ module, or migrate the code first.', }; // Build a ban group for a specific legacy directory that bans all OTHER legacy // directories but allows same-directory imports (intra-legacy is OK). function legacyCrossBanGroup(ownPatterns) { const ownSet = new Set(ownPatterns); return { group: allLegacyPatterns.filter((p) => !ownSet.has(p)), message: 'Legacy directories must not import from other legacy directories. Migrate the dependency to shared/ first.', }; } function withLayerBoundaries(patterns) { return [ 'error', { paths: baseRestrictedImportPaths, patterns, }, ]; } function withLayerBoundariesAndLegacyBan(patterns) { return [ 'error', { paths: baseRestrictedImportPaths, patterns: [...patterns, legacyBanGroup], }, ]; } module.exports = { root: true, env: { browser: true, es2020: true, }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:i18next/recommended', 'plugin:eslint-comments/recommended', 'prettier', ], ignorePatterns: ['dist', '.eslintrc.cjs', 'src/routeTree.gen.ts'], parser: '@typescript-eslint/parser', plugins: ['react-refresh', '@typescript-eslint', 'unused-imports', 'i18next', 'eslint-comments', 'check-file', 'deprecation'], parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: path.join(__dirname, 'tsconfig.json'), }, rules: { 'eslint-comments/no-use': ['error', { allow: [] }], 'react-refresh/only-export-components': 'off', 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ 'error', { vars: 'all', args: 'after-used', ignoreRestSiblings: false, }, ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/switch-exhaustiveness-check': 'error', // Enforce typesafe modal pattern 'no-restricted-imports': [ 'error', { paths: baseRestrictedImportPaths, }, ], 'no-restricted-syntax': [ 'error', { selector: 'CallExpression[callee.object.name="NiceModal"][callee.property.name="show"]', message: 'Do not use NiceModal.show() directly. Use DialogName.show(props) instead.', }, { selector: 'CallExpression[callee.object.name="NiceModal"][callee.property.name="register"]', message: 'Do not use NiceModal.register(). Dialogs are registered automatically.', }, { selector: 'CallExpression[callee.name="showModal"]', message: 'Do not use showModal(). Use DialogName.show(props) instead.', }, { selector: 'CallExpression[callee.name="hideModal"]', message: 'Do not use hideModal(). Use DialogName.hide() instead.', }, { selector: 'CallExpression[callee.name="removeModal"]', message: 'Do not use removeModal(). Use DialogName.remove() instead.', }, { selector: 'ExportNamedDeclaration[source]', message: 'Re-exports are not allowed. Import directly from the owning module instead.', }, ], // i18n rule - only active when LINT_I18N=true 'i18next/no-literal-string': i18nCheck ? [ 'warn', { markupOnly: true, ignoreAttribute: [ 'data-testid', 'to', 'href', 'id', 'key', 'type', 'role', 'className', 'style', 'aria-describedby', ], 'jsx-components': { exclude: ['code'], }, }, ] : 'off', // File naming conventions 'check-file/filename-naming-convention': [ 'error', { // React components (tsx) should be PascalCase 'src/**/*.tsx': 'PASCAL_CASE', // Hooks should be camelCase starting with 'use' 'src/**/use*.ts': 'CAMEL_CASE', // Utils should be camelCase 'src/utils/**/*.ts': 'CAMEL_CASE', // Lib/config/constants should be camelCase 'src/lib/**/*.ts': 'CAMEL_CASE', 'src/config/**/*.ts': 'CAMEL_CASE', 'src/constants/**/*.ts': 'CAMEL_CASE', }, { ignoreMiddleExtensions: true, }, ], }, overrides: [ { // Legacy directories sit outside the enforced layer hierarchy. // They cannot import from higher layers (app/pages/widgets/features/entities) // and cannot import from other legacy directories — but CAN import from // themselves (intra-directory imports are fine). files: ['src/hooks/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundaries([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'], message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.', }, legacyCrossBanGroup(['@/hooks', '@/hooks/**']), ]), }, }, { files: ['src/contexts/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundaries([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'], message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.', }, legacyCrossBanGroup(['@/contexts', '@/contexts/**']), ]), }, }, { files: ['src/lib/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundaries([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'], message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.', }, legacyCrossBanGroup(['@/lib', '@/lib/**']), ]), }, }, { files: ['src/components/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundaries([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'], message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.', }, legacyCrossBanGroup(['@/components', '@/components/**']), ]), }, }, { files: [ 'src/utils/**/*.{ts,tsx}', 'src/constants/**/*.{ts,tsx}', 'src/types/**/*.{ts,tsx}', 'src/keyboard/**/*.{ts,tsx}', ], rules: { 'no-restricted-imports': withLayerBoundaries([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**'], message: 'Legacy directories are treated as shared-level. They may only import from shared and integrations.', }, legacyCrossBanGroup([ '@/utils', '@/utils/**', '@/constants', '@/constants/**', '@/types', '@/types/**', '@/keyboard', '@/keyboard/**', ]), ]), }, }, { // Pages may import from widgets, features, entities, shared, integrations // but not from legacy directories. files: ['src/pages/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: ['@/app/**'], message: 'Pages may not import from app. Only app imports pages.', }, ]), }, }, { // App layer may import from any proper layer but not legacy directories. files: ['src/app/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([]), }, }, { // Route definitions may import from pages and shared but not legacy. files: ['src/routes/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([]), }, }, { files: ['src/routes/**/*.{ts,tsx}', 'src/routeTree.gen.ts'], rules: { 'check-file/filename-naming-convention': 'off', }, }, { files: ['src/widgets/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: ['@/app/**', '@/pages/**', '@/widgets/**'], message: 'Widgets may only import from features, entities, shared, and integrations.', }, ]), }, }, { files: ['src/features/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: ['@/app/**', '@/pages/**', '@/widgets/**', '@/features/**'], message: 'Features may only import from entities, shared, and integrations.', }, ]), 'no-restricted-syntax': [ 'error', { selector: 'ExportNamedDeclaration[source.value=/^@\\u002F/]', message: 'Re-exports from other layers in features are not allowed. They bypass layer boundaries. Import directly from the owning module instead.', }, { selector: 'ExportAllDeclaration[source.value=/^@\\u002F/]', message: 'Wildcard re-exports from other layers in features are not allowed. They bypass layer boundaries.', }, ], }, }, { files: ['src/entities/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: [ '@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**', ], message: 'Entities may only import from shared and integrations.', }, ]), }, }, { files: ['src/shared/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: [ '@/app/**', '@/pages/**', '@/widgets/**', '@/features/**', '@/entities/**', '@/integrations/**', ], message: 'Shared layer may only import from shared.', }, ]), }, }, { files: ['src/integrations/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': withLayerBoundariesAndLegacyBan([ { group: ['@/app/**', '@/pages/**', '@/widgets/**'], message: 'Integrations must not depend on app/pages/widgets. Use shared-level contracts instead.', }, ]), }, }, { // Entry point exception - main.tsx can stay lowercase files: ['src/main.tsx', 'src/vite-env.d.ts'], rules: { 'check-file/filename-naming-convention': 'off', }, }, { // Shadcn UI components are an exception - keep kebab-case files: ['src/components/ui/**/*.{ts,tsx}'], rules: { 'check-file/filename-naming-convention': [ 'error', { 'src/components/ui/**/*.{ts,tsx}': 'KEBAB_CASE', }, { ignoreMiddleExtensions: true, }, ], }, }, { files: [ '**/*.test.{ts,tsx}', '**/*.stories.{ts,tsx}', 'src/pages/ui-new/ElectricTestPage.tsx', 'src/pages/Migration.tsx', 'src/components/ui-new/views/Migrate*.tsx', 'src/components/ui-new/containers/Migrate*.tsx', ], rules: { 'i18next/no-literal-string': 'off', }, }, { // Disable type-aware linting for config files files: ['*.config.{ts,js,cjs,mjs}', '.eslintrc.cjs'], parserOptions: { project: null, }, rules: { '@typescript-eslint/switch-exhaustiveness-check': 'off', }, }, { // i18n index re-exports the default export from config for `import i18n from '@/i18n'` files: ['src/i18n/index.ts'], rules: { 'no-restricted-syntax': 'off', }, }, { // ui-new components must use Phosphor icons (not Lucide) and avoid deprecated APIs files: ['src/components/ui-new/**/*.{ts,tsx}'], rules: { 'deprecation/deprecation': 'error', 'no-restricted-imports': [ 'error', { paths: [ { name: '@vibe/ui', message: 'Do not import from @vibe/ui root. Use @vibe/ui/components/* subpaths.', }, { name: 'lucide-react', message: 'Use @phosphor-icons/react instead of lucide-react in ui-new components.', }, ], }, ], // Icon size restrictions - use Tailwind design system sizes 'no-restricted-syntax': [ 'error', { selector: 'JSXAttribute[name.name="size"][value.type="JSXExpressionContainer"]', message: 'Icons should use Tailwind size classes (size-icon-xs, size-icon-sm, size-icon-base, size-icon-lg, size-icon-xl) instead of the size prop. Example: ', }, { // Catch arbitrary pixel sizes like size-[10px], size-[7px], etc. in className selector: 'Literal[value=/size-\\[\\d+px\\]/]', message: 'Use standard icon sizes (size-icon-xs, size-icon-sm, size-icon-base, size-icon-lg, size-icon-xl) instead of arbitrary pixel values like size-[Npx].', }, { // Catch generic tailwind sizes like size-1, size-3, size-1.5, etc. (not size-icon-* or size-dot) selector: 'Literal[value=/(? Vibe Kanban
================================================ FILE: packages/local-web/package.json ================================================ { "name": "@vibe/local-web", "private": true, "version": "0.1.33", "type": "module", "scripts": { "dev": "VITE_OPEN=${VITE_OPEN:-false} vite", "build": "tsc && vite build", "check": "tsc --noEmit", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --ext ts,tsx --fix", "lint:i18n": "LINT_I18N=true eslint . --ext ts,tsx --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" }, "dependencies": { "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.11.2", "@codemirror/lint": "^6.8.5", "@codemirror/view": "^6.38.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@ebay/nice-modal-react": "^1.2.13", "@git-diff-view/file": "^0.0.30", "@git-diff-view/react": "^0.0.30", "@hello-pangea/dnd": "^18.0.1", "@lexical/code": "^0.36.2", "@lexical/link": "^0.36.2", "@lexical/list": "^0.36.2", "@lexical/markdown": "^0.36.2", "@lexical/react": "^0.36.2", "@lexical/rich-text": "^0.36.2", "@lexical/table": "^0.36.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.0.8", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.7", "@rjsf/shadcn": "6.1.1", "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", "@tanstack/electric-db-collection": "^0.2.6", "@tanstack/react-db": "^0.1.50", "@tanstack/react-form": "^1.23.8", "@tanstack/react-query": "^5.85.5", "@tanstack/react-router": "^1.161.1", "@tanstack/zod-adapter": "^1.161.1", "@tauri-apps/api": "^2.10.1", "@uiw/react-codemirror": "^4.25.1", "@vibe/web-core": "workspace:*", "@vibe/ui": "workspace:*", "@virtuoso.dev/message-list": "^1.13.3", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "click-to-react-component": "^1.1.2", "clsx": "^2.0.0", "cmdk": "^1.1.1", "developer-icons": "^6.0.4", "fancy-ansi": "^0.1.3", "framer-motion": "^12.23.24", "i18next": "^25.5.2", "i18next-browser-languagedetector": "^8.2.0", "immer": "^11.1.3", "jwt-decode": "^4.0.0", "lexical": "^0.36.2", "lodash": "^4.17.21", "lucide-react": "^0.539.0", "posthog-js": "^1.276.0", "react": "^18.2.0", "react-compiler-runtime": "^1.0.0", "react-dom": "^18.2.0", "react-dropzone": "^14.3.8", "react-hotkeys-hook": "^5.1.0", "react-i18next": "^15.7.3", "react-resizable-panels": "^4.0.13", "react-use-websocket": "^4.13.0", "react-virtuoso": "^4.14.0", "rfc6902": "^5.1.2", "simple-icons": "^15.16.0", "tailwind-merge": "^2.2.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", "wa-sqlite": "^1.0.0", "zod": "^3.25.76", "zustand": "^4.5.4" }, "devDependencies": { "@rjsf/core": "6.1.1", "@rjsf/utils": "6.1.1", "@rjsf/validator-ajv8": "6.1.1", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/router-plugin": "^1.161.1", "@types/lodash": "^4.17.20", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "babel-plugin-react-compiler": "^1.0.0", "eslint": "^8.55.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-check-file": "^2.8.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-i18next": "^6.1.3", "eslint-plugin-prettier": "^5.5.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-unused-imports": "^4.1.4", "postcss": "^8.4.32", "prettier": "^3.6.1", "tailwindcss": "^3.4.0", "typescript": "^5.9.2", "vite": "^7.3.1" } } ================================================ FILE: packages/local-web/postcss.config.cjs ================================================ module.exports = { plugins: { // No config specified - @config directives in CSS files take precedence tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: packages/local-web/src/app/entry/App.tsx ================================================ import { RouterProvider } from '@tanstack/react-router'; import { HotkeysProvider } from 'react-hotkeys-hook'; import { UserSystemProvider } from '@web/app/providers/ConfigProvider'; import { ClickedElementsProvider } from '@web/app/providers/ClickedElementsProvider'; import { localAppNavigation } from '@web/app/navigation/AppNavigation'; import { LocalAuthProvider } from '@/shared/providers/auth/LocalAuthProvider'; import { AppRuntimeProvider } from '@/shared/hooks/useAppRuntime'; import { AppNavigationProvider } from '@/shared/hooks/useAppNavigation'; import { useTauriNotificationNavigation } from '@web/app/hooks/useTauriNotificationNavigation'; import { useTauriUpdateReady } from '@web/app/hooks/useTauriUpdateReady'; import { AppSystemNotifications } from '@web/app/notifications/AppSystemNotifications'; import { router } from '@web/app/router'; function TauriListeners() { useTauriNotificationNavigation(); useTauriUpdateReady(); return null; } function App() { return ( ); } export default App; ================================================ FILE: packages/local-web/src/app/entry/Bootstrap.tsx ================================================ import React from 'react'; import ReactDOM from 'react-dom/client'; import * as Sentry from '@sentry/react'; import { ClickToComponent } from 'click-to-react-component'; import { QueryClientProvider } from '@tanstack/react-query'; import posthog from 'posthog-js'; import { PostHogProvider } from 'posthog-js/react'; import App from '@web/app/entry/App'; import i18n from '@/i18n'; import { router } from '@web/app/router'; import { oauthApi } from '@/shared/lib/api'; import { tokenManager } from '@/shared/lib/auth/tokenManager'; import { configureAuthRuntime } from '@/shared/lib/auth/runtime'; import '@/shared/types/modals'; import { queryClient } from '@/shared/lib/queryClient'; import { isTauriApp } from '@/shared/lib/platform'; import { initZoom, zoomIn, zoomOut, zoomReset } from '@/shared/lib/zoom'; if (import.meta.env.VITE_SENTRY_DSN) { Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, tracesSampleRate: 1.0, environment: import.meta.env.MODE === 'development' ? 'dev' : 'production', integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], }); Sentry.setTag('source', 'frontend'); } if ( import.meta.env.VITE_POSTHOG_API_KEY && import.meta.env.VITE_POSTHOG_API_ENDPOINT ) { posthog.init(import.meta.env.VITE_POSTHOG_API_KEY, { api_host: import.meta.env.VITE_POSTHOG_API_ENDPOINT, capture_pageview: false, capture_pageleave: true, capture_performance: true, autocapture: false, opt_out_capturing_by_default: true, }); } else { console.warn( 'PostHog API key or endpoint not set. Analytics will be disabled.' ); } // In the Tauri desktop app, implement custom zoom (Cmd/Ctrl + =/–/0) via root // font-size scaling and block trackpad/touchpad pinch-to-zoom. if (isTauriApp()) { initZoom(); document.addEventListener('keydown', (e) => { const mod = e.metaKey || e.ctrlKey; if (!mod) return; if (e.key === '=' || e.key === '+') { e.preventDefault(); zoomIn(); } else if (e.key === '-') { e.preventDefault(); zoomOut(); } else if (e.key === '0') { e.preventDefault(); zoomReset(); } }); document.addEventListener( 'wheel', (e) => { if (e.ctrlKey) e.preventDefault(); }, { passive: false } ); document.addEventListener('gesturestart', (e) => e.preventDefault()); document.addEventListener('gesturechange', (e) => e.preventDefault()); } configureAuthRuntime({ getToken: () => tokenManager.getToken(), triggerRefresh: () => tokenManager.triggerRefresh(), registerShape: (shape) => tokenManager.registerShape(shape), getCurrentUser: () => oauthApi.getCurrentUser(), }); ReactDOM.createRoot(document.getElementById('root')!).render( {i18n.t('common:states.error')}

} showDialog >
); ================================================ FILE: packages/local-web/src/app/hooks/useTauriNotificationNavigation.ts ================================================ /** * Listens for `navigate-to-workspace` events emitted by the Tauri backend * when a notification fires. * * Auto-navigation is temporarily disabled — the user handles navigation * manually for now. */ export function useTauriNotificationNavigation() { // noop — auto-navigation disabled for now } ================================================ FILE: packages/local-web/src/app/hooks/useTauriUpdateReady.ts ================================================ import { useEffect } from 'react'; import { isTauriApp } from '@/shared/lib/platform'; import { useAppUpdateStore } from '@/shared/stores/useAppUpdateStore'; /** * Listens for the `update-installed` event emitted by the Tauri backend * after an update has been silently downloaded and applied. Sets the * shared update store so the AppBar can show a restart button. */ export function useTauriUpdateReady() { const setUpdate = useAppUpdateStore((s) => s.setUpdate); useEffect(() => { if (!isTauriApp()) return; let unlisten: (() => void) | undefined; async function setup() { const { listen, emit } = await import('@tauri-apps/api/event'); unlisten = await listen<{ newVersion: string }>( 'update-installed', (event) => { setUpdate(event.payload.newVersion, () => { emit('restart-app'); }); } ); } setup(); return () => { unlisten?.(); }; }, [setUpdate]); } ================================================ FILE: packages/local-web/src/app/navigation/AppNavigation.ts ================================================ import { router } from '@web/app/router'; import type { FileRouteTypes } from '@web/routeTree.gen'; import { type AppDestination, type AppNavigation, type NavigationTransition, } from '@/shared/lib/routes/appNavigation'; type LocalRouteId = FileRouteTypes['id']; function getPathParam( routeParams: Record, key: string ): string | null { const value = routeParams[key]; return value ? value : null; } function resolveLocalDestinationFromPath(path: string): AppDestination | null { const { pathname } = new URL(path, 'http://localhost'); const { foundRoute, routeParams } = router.getMatchedRoutes(pathname); if (!foundRoute) { return null; } switch (foundRoute.id as LocalRouteId) { case '/': return { kind: 'root' }; case '/onboarding': return { kind: 'onboarding' }; case '/onboarding_/sign-in': return { kind: 'onboarding-sign-in' }; case '/_app/migrate': return { kind: 'migrate' }; case '/_app/workspaces': return { kind: 'workspaces' }; case '/_app/workspaces_/create': return { kind: 'workspaces-create' }; case '/_app/workspaces_/$workspaceId': { const workspaceId = getPathParam(routeParams, 'workspaceId'); return workspaceId ? { kind: 'workspace', workspaceId } : null; } case '/workspaces/$workspaceId/vscode': { const workspaceId = getPathParam(routeParams, 'workspaceId'); return workspaceId ? { kind: 'workspace-vscode', workspaceId } : null; } case '/_app/projects/$projectId': { const projectId = getPathParam(routeParams, 'projectId'); return projectId ? { kind: 'project', projectId } : null; } case '/_app/projects/$projectId_/issues/$issueId': { const projectId = getPathParam(routeParams, 'projectId'); const issueId = getPathParam(routeParams, 'issueId'); return projectId && issueId ? { kind: 'project-issue', projectId, issueId } : null; } case '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': { const projectId = getPathParam(routeParams, 'projectId'); const issueId = getPathParam(routeParams, 'issueId'); const workspaceId = getPathParam(routeParams, 'workspaceId'); return projectId && issueId && workspaceId ? { kind: 'project-issue-workspace', projectId, issueId, workspaceId, } : null; } case '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': { const projectId = getPathParam(routeParams, 'projectId'); const issueId = getPathParam(routeParams, 'issueId'); const draftId = getPathParam(routeParams, 'draftId'); return projectId && issueId && draftId ? { kind: 'project-issue-workspace-create', projectId, issueId, draftId, } : null; } case '/_app/projects/$projectId_/workspaces/create/$draftId': { const projectId = getPathParam(routeParams, 'projectId'); const draftId = getPathParam(routeParams, 'draftId'); return projectId && draftId ? { kind: 'project-workspace-create', projectId, draftId, } : null; } default: return null; } } function destinationToLocalTarget(destination: AppDestination) { switch (destination.kind) { case 'root': return { to: '/' } as const; case 'onboarding': return { to: '/onboarding' } as const; case 'onboarding-sign-in': return { to: '/onboarding/sign-in' } as const; case 'migrate': return { to: '/migrate' } as const; case 'workspaces': return { to: '/workspaces' } as const; case 'workspaces-create': return { to: '/workspaces/create' } as const; case 'workspace': return { to: '/workspaces/$workspaceId', params: { workspaceId: destination.workspaceId }, } as const; case 'workspace-vscode': return { to: '/workspaces/$workspaceId/vscode', params: { workspaceId: destination.workspaceId }, } as const; case 'project': return { to: '/projects/$projectId', params: { projectId: destination.projectId }, } as const; case 'project-issue': return { to: '/projects/$projectId/issues/$issueId', params: { projectId: destination.projectId, issueId: destination.issueId, }, } as const; case 'project-issue-workspace': return { to: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId', params: { projectId: destination.projectId, issueId: destination.issueId, workspaceId: destination.workspaceId, }, } as const; case 'project-issue-workspace-create': return { to: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId', params: { projectId: destination.projectId, issueId: destination.issueId, draftId: destination.draftId, }, } as const; case 'project-workspace-create': return { to: '/projects/$projectId/workspaces/create/$draftId', params: { projectId: destination.projectId, draftId: destination.draftId, }, } as const; } } export function createLocalAppNavigation(): AppNavigation { const navigateTo = ( destination: AppDestination, transition?: NavigationTransition ) => { void router.navigate({ ...destinationToLocalTarget(destination), ...(transition?.replace !== undefined ? { replace: transition.replace } : {}), }); }; const navigation: AppNavigation = { resolveFromPath: (path) => resolveLocalDestinationFromPath(path), goToRoot: (transition) => navigateTo({ kind: 'root' }, transition), goToOnboarding: (transition) => navigateTo({ kind: 'onboarding' }, transition), goToOnboardingSignIn: (transition) => navigateTo({ kind: 'onboarding-sign-in' }, transition), goToMigrate: (transition) => navigateTo({ kind: 'migrate' }, transition), goToWorkspaces: (transition) => navigateTo({ kind: 'workspaces' }, transition), goToWorkspacesCreate: (transition) => navigateTo({ kind: 'workspaces-create' }, transition), goToWorkspace: (workspaceId, transition) => navigateTo({ kind: 'workspace', workspaceId }, transition), goToWorkspaceVsCode: (workspaceId, transition) => navigateTo({ kind: 'workspace-vscode', workspaceId }, transition), goToProject: (projectId, transition) => navigateTo({ kind: 'project', projectId }, transition), goToProjectIssue: (projectId, issueId, transition) => navigateTo({ kind: 'project-issue', projectId, issueId }, transition), goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) => navigateTo( { kind: 'project-issue-workspace', projectId, issueId, workspaceId }, transition ), goToProjectIssueWorkspaceCreate: ( projectId, issueId, draftId, transition ) => navigateTo( { kind: 'project-issue-workspace-create', projectId, issueId, draftId }, transition ), goToProjectWorkspaceCreate: (projectId, draftId, transition) => navigateTo( { kind: 'project-workspace-create', projectId, draftId }, transition ), }; return navigation; } export const localAppNavigation = createLocalAppNavigation(); ================================================ FILE: packages/local-web/src/app/notifications/AppSystemNotifications.tsx ================================================ import { useEffect, useRef } from 'react'; import { useAuth } from '@/shared/hooks/auth/useAuth'; import { useNotificationMembers } from '@/shared/hooks/useNotificationMembers'; import { useNotifications } from '@/shared/hooks/useNotifications'; import { getGroupedNotificationText } from '@/shared/lib/notificationMessage'; import { showSystemNotification } from '@web/app/notifications/showSystemNotification'; export function AppSystemNotifications() { const { userId } = useAuth(); const { data, enabled, groupedNotifications } = useNotifications(); const { membersByUserId, isLoading, isFetching } = useNotificationMembers(data); const displayedNotificationIdsRef = useRef(new Set()); const initializedRef = useRef(false); useEffect(() => { displayedNotificationIdsRef.current.clear(); initializedRef.current = false; }, [userId]); useEffect(() => { if (!enabled || isLoading || isFetching) { return; } if (!initializedRef.current) { for (const group of groupedNotifications) { if (!group.seen) { displayedNotificationIdsRef.current.add(group.id); } } initializedRef.current = true; return; } const activeGroupIds = new Set( groupedNotifications.map((group) => group.id) ); for (const id of displayedNotificationIdsRef.current) { if (!activeGroupIds.has(id)) { displayedNotificationIdsRef.current.delete(id); } } for (const group of groupedNotifications) { if (group.seen || displayedNotificationIdsRef.current.has(group.id)) { continue; } displayedNotificationIdsRef.current.add(group.id); void showSystemNotification({ id: group.id, title: 'Vibe Kanban', body: getGroupedNotificationText(group, membersByUserId), }); } }, [enabled, groupedNotifications, isFetching, isLoading, membersByUserId]); return null; } ================================================ FILE: packages/local-web/src/app/notifications/showSystemNotification.ts ================================================ import { invoke } from '@tauri-apps/api/core'; import { isTauriApp } from '@/shared/lib/platform'; interface NotificationPayload { id: string; title: string; body: string; } export async function showSystemNotification( notification: NotificationPayload ): Promise { if (!isTauriApp()) { return; } try { await invoke('show_system_notification', { title: notification.title, body: notification.body, }); } catch (error) { console.error( `Failed to show system notification for group ${notification.id}:`, error ); } } ================================================ FILE: packages/local-web/src/app/providers/ClickedElementsProvider.tsx ================================================ import { useContext, useState, ReactNode, useEffect, useCallback } from 'react'; import { createHmrContext } from '@/shared/lib/hmrContext'; import type { OpenInEditorPayload, ComponentInfo, SelectedComponent, } from '@/shared/lib/previewBridge'; import type { Workspace } from 'shared/types'; import { genId } from '@/shared/lib/id'; export interface ClickedEntry { id: string; payload: OpenInEditorPayload; timestamp: number; dedupeKey: string; selectedDepth?: number; // 0 = innermost (selected), 1 = parent, etc. } interface ClickedElementsContextType { elements: ClickedEntry[]; addElement: (payload: OpenInEditorPayload) => void; removeElement: (id: string) => void; clearElements: () => void; selectComponent: (id: string, depthFromInner: number) => void; generateMarkdown: () => string; } const ClickedElementsContext = createHmrContext( 'ClickedElementsContext', null ); export function useClickedElements() { const context = useContext(ClickedElementsContext); if (!context) { throw new Error( 'useClickedElements must be used within a ClickedElementsProvider' ); } return context; } interface ClickedElementsProviderProps { children: ReactNode; attempt?: Workspace | null; } const MAX_ELEMENTS = 20; // Helpers function stripPrefixes(p?: string): string { if (!p) return ''; return p .replace(/^file:\/\//, '') .replace(/^webpack:\/\/\//, '') .replace(/^webpack:\/\//, '') .trim(); } // macOS alias handling; no-ops on other OSes function normalizeMacPrivateAliases(p: string): string { if (!p) return p; // Very light normalization mimicking path.rs logic if (p === '/private/var') return '/var'; if (p.startsWith('/private/var/')) return '/var/' + p.slice('/private/var/'.length); if (p === '/private/tmp') return '/tmp'; if (p.startsWith('/private/tmp/')) return '/tmp/' + p.slice('/private/tmp/'.length); return p; } // Return { path, line?, col? } where `path` has no trailing :line(:col). // Works even when Windows drive letters contain a colon. function parsePathWithLineCol(raw?: string): { path: string; line?: number; col?: number; } { const s = stripPrefixes(raw); if (!s) return { path: '' }; const normalized = normalizeMacPrivateAliases(s); // Try to split trailing :line(:col). Last and second-to-last tokens must be numbers. const parts = normalized.split(':'); if (parts.length <= 2) return { path: normalized }; const last = parts[parts.length - 1]; const maybeCol = Number(last); if (!Number.isFinite(maybeCol)) return { path: normalized }; const prev = parts[parts.length - 2]; const maybeLine = Number(prev); if (!Number.isFinite(maybeLine)) return { path: normalized }; // Windows drive (e.g., "C") is at index 0; this still works because we only strip the end const basePath = parts.slice(0, parts.length - 2).join(':'); return { path: basePath, line: maybeLine, col: maybeCol }; } function relativizePath(p: string, workspaceRoot?: string): string { if (!p) return ''; const normalized = normalizeMacPrivateAliases(stripPrefixes(p)); if (!workspaceRoot) return normalized; // Simple prefix strip; robust handling is on backend (path.rs). // This keeps the UI stable even when run inside macOS /private/var containers. const wr = normalizeMacPrivateAliases(workspaceRoot.replace(/\/+$/, '')); if ( normalized.startsWith(wr.endsWith('/') ? wr : wr + '/') || normalized === wr ) { const rel = normalized.slice(wr.length); return rel.startsWith('/') ? rel.slice(1) : rel || '.'; } return normalized; } function formatLoc(path: string, line?: number, col?: number) { if (!path) return ''; if (line == null) return path; return `${path}:${line}${col != null ? `:${col}` : ''}`; } function formatDomBits(ce?: OpenInEditorPayload['clickedElement']) { const bits: string[] = []; if (ce?.tag) bits.push(ce.tag.toLowerCase()); if (ce?.id) bits.push(`#${ce.id}`); const classes = normalizeClassName(ce?.className); if (classes) bits.push(`.${classes}`); if (ce?.role) bits.push(`@${ce.role}`); return bits.join('') || '(unknown)'; } function normalizeClassName(className?: string): string { if (!className) return ''; return className.split(/\s+/).filter(Boolean).sort().join('.'); } function makeDedupeKey( payload: OpenInEditorPayload, workspaceRoot?: string ): string { const s = payload.selected; const ce = payload.clickedElement; const { path } = parsePathWithLineCol(s.pathToSource); const rel = relativizePath(path, workspaceRoot); const domBits: string[] = []; if (ce?.tag) domBits.push(ce.tag.toLowerCase()); if (ce?.id) domBits.push(`#${ce.id}`); const normalizedClasses = normalizeClassName(ce?.className); if (normalizedClasses) domBits.push(`.${normalizedClasses}`); if (ce?.role) domBits.push(`@${ce.role}`); const locKey = [ rel, s.source?.lineNumber ?? '', s.source?.columnNumber ?? '', ].join(':'); return `${s.name}|${locKey}|${domBits.join('')}`; } // Remove heavy or unsafe props while retaining debuggability function pruneValue( value: unknown, depth: number, maxString = 200, maxArray = 20 ): unknown { if (depth <= 0) return '[MaxDepth]'; if (value == null) return value; const t = typeof value; if (t === 'string') return (value as string).length > maxString ? (value as string).slice(0, maxString) + '…' : value; if (t === 'number' || t === 'boolean') return value; if (t === 'function') return '[Function]'; if (t === 'bigint') return value.toString() + 'n'; if (t === 'symbol') return value.toString(); if (Array.isArray(value)) { const lim = (value as unknown[]) .slice(0, maxArray) .map((v) => pruneValue(v, depth - 1, maxString, maxArray)); if ((value as unknown[]).length > maxArray) lim.push(`[+${(value as unknown[]).length - maxArray} more]`); return lim; } if (t === 'object') { const obj = value as Record; const out: Record = {}; let count = 0; for (const k of Object.keys(obj)) { // Cap keys to keep small if (count++ > 50) { out['[TruncatedKeys]'] = true; break; } out[k] = pruneValue(obj[k], depth - 1, maxString, maxArray); } return out; } return '[Unknown]'; } function stripHeavyProps(payload: OpenInEditorPayload): OpenInEditorPayload { // Avoid mutating caller objects const shallowSelected = { ...payload.selected, props: pruneValue(payload.selected.props, 2) as Record, }; const shallowComponents = payload.components.map((c) => ({ ...c, props: pruneValue(c.props, 2) as Record, })); // dataset and coords are typically small; keep as-is. return { ...payload, selected: shallowSelected, components: shallowComponents, }; } // Build component chain from inner-most to outer-most function buildChainInnerToOuter( payload: OpenInEditorPayload, workspaceRoot?: string ) { const comps = payload.components ?? []; const s = payload.selected; // Start with the selected component as innermost const innerToOuter: (ComponentInfo | SelectedComponent)[] = [s]; // Add components that aren't duplicates of selected const selectedKey = `${s.name}|${s.pathToSource}|${s.source?.lineNumber}|${s.source?.columnNumber}`; comps.forEach((c) => { const compKey = `${c.name}|${c.pathToSource}|${c.source?.lineNumber}|${c.source?.columnNumber}`; if (compKey !== selectedKey) { innerToOuter.push(c); } }); // Remove duplicates by creating unique keys const seen = new Set(); return innerToOuter.filter((c) => { const parsed = parsePathWithLineCol(c.pathToSource); const rel = relativizePath(parsed.path, workspaceRoot); const loc = formatLoc( rel, c.source?.lineNumber ?? parsed.line, c.source?.columnNumber ?? parsed.col ); const key = `${c.name}|${loc}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); } function formatClickedMarkdown( entry: ClickedEntry, workspaceRoot?: string ): string { const { payload, selectedDepth = 0 } = entry; const chain = buildChainInnerToOuter(payload, workspaceRoot); const effectiveChain = chain.slice(selectedDepth); // Start from selected anchor outward // DOM const dom = formatDomBits(payload.clickedElement); // Use first component in effective chain as the "selected start" const first = effectiveChain[0]; const parsed = parsePathWithLineCol(first.pathToSource); const rel = relativizePath(parsed.path, workspaceRoot); const loc = formatLoc( rel, first.source?.lineNumber ?? parsed.line, first.source?.columnNumber ?? parsed.col ); // Build hierarchy from effective chain const items = effectiveChain.map((c, i) => { const p = parsePathWithLineCol(c.pathToSource); const r = relativizePath(p.path, workspaceRoot); const l = formatLoc( r, c.source?.lineNumber ?? p.line, c.source?.columnNumber ?? p.col ); const indent = ' '.repeat(i); const arrow = i > 0 ? '└─ ' : ''; const tag = i === 0 ? ' ← start' : ''; return `${indent}${arrow}${c.name} (\`${l || 'no source'}\`)${tag}`; }); return [ `From preview click:`, `- DOM: ${dom}`, `- Selected start: ${first.name} (${loc ? `\`${loc}\`` : 'no source'})`, effectiveChain.length > 1 ? ['- Component hierarchy:', ...items].join('\n') : '', ] .filter(Boolean) .join('\n'); } export function ClickedElementsProvider({ children, attempt, }: ClickedElementsProviderProps) { const [elements, setElements] = useState([]); const workspaceRoot = attempt?.container_ref; // Clear elements when attempt changes useEffect(() => { setElements([]); }, [attempt?.id]); const addElement = (payload: OpenInEditorPayload) => { const sanitized = stripHeavyProps(payload); const dedupeKey = makeDedupeKey(sanitized, workspaceRoot || undefined); setElements((prev) => { const last = prev[prev.length - 1]; if (last && last.dedupeKey === dedupeKey) { return prev; // Skip consecutive duplicate } const newEntry: ClickedEntry = { id: genId(), payload: sanitized, timestamp: Date.now(), dedupeKey, }; const updated = [...prev, newEntry]; return updated.length > MAX_ELEMENTS ? updated.slice(-MAX_ELEMENTS) : updated; }); }; const removeElement = (id: string) => { setElements((prev) => prev.filter((e) => e.id !== id)); }; const clearElements = () => { setElements([]); }; const selectComponent = (id: string, depthFromInner: number) => { setElements((prev) => prev.map((e) => e.id === id ? { ...e, selectedDepth: depthFromInner } : e ) ); }; const generateMarkdown = useCallback(() => { if (elements.length === 0) return ''; const header = `## Clicked Elements (${elements.length})\n\n`; const body = elements .map((e) => formatClickedMarkdown(e, workspaceRoot || undefined)) .join('\n\n'); return header + body; }, [elements, workspaceRoot]); return ( {children} ); } ================================================ FILE: packages/local-web/src/app/providers/ConfigProvider.tsx ================================================ import { ReactNode, useCallback, useEffect, useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { type Config, type Environment, type UserSystemInfo, type BaseAgentCapability, } from 'shared/types'; import type { ExecutorProfile } from 'shared/types'; import { configApi } from '@/shared/lib/api'; import { updateLanguageFromConfig } from '@/i18n/config'; import { setRemoteApiBase } from '@/shared/lib/remoteApi'; import { UserSystemContext, type UserSystemContextType, } from '@/shared/hooks/useUserSystem'; interface UserSystemProviderProps { children: ReactNode; } export function UserSystemProvider({ children }: UserSystemProviderProps) { const queryClient = useQueryClient(); const { data: userSystemInfo, isLoading } = useQuery({ queryKey: ['user-system'], queryFn: configApi.getConfig, staleTime: 5 * 60 * 1000, // 5 minutes }); const config = userSystemInfo?.config || null; const appVersion = userSystemInfo?.version || null; const environment = userSystemInfo?.environment || null; const analyticsUserId = userSystemInfo?.analytics_user_id || null; const loginStatus = userSystemInfo?.login_status || null; const profiles = (userSystemInfo?.executors as Record | null) || null; const capabilities = (userSystemInfo?.capabilities as Record< string, BaseAgentCapability[] > | null) || null; // Set runtime remote API base URL for self-hosting support. // Must run during render (not in useEffect) so it's set before children mount. setRemoteApiBase(userSystemInfo?.shared_api_base); // Sync language with i18n when config changes useEffect(() => { if (config?.language) { updateLanguageFromConfig(config.language); } }, [config?.language]); const updateConfig = useCallback( (updates: Partial) => { queryClient.setQueryData(['user-system'], (old) => { if (!old) return old; return { ...old, config: { ...old.config, ...updates }, }; }); }, [queryClient] ); const saveConfig = useCallback(async (): Promise => { if (!config) return false; try { await configApi.saveConfig(config); return true; } catch (err) { console.error('Error saving config:', err); return false; } }, [config]); const updateAndSaveConfig = useCallback( async (updates: Partial): Promise => { if (!config) return false; const newConfig = { ...config, ...updates }; updateConfig(updates); try { const saved = await configApi.saveConfig(newConfig); queryClient.setQueryData(['user-system'], (old) => { if (!old) return old; return { ...old, config: saved, }; }); return true; } catch (err) { console.error('Error saving config:', err); queryClient.invalidateQueries({ queryKey: ['user-system'] }); return false; } }, [config, queryClient, updateConfig] ); const reloadSystem = useCallback(async () => { await queryClient.invalidateQueries({ queryKey: ['user-system'] }); }, [queryClient]); const setEnvironment = useCallback( (env: Environment | null) => { queryClient.setQueryData(['user-system'], (old) => { if (!old || !env) return old; return { ...old, environment: env }; }); }, [queryClient] ); const setProfiles = useCallback( (newProfiles: Record | null) => { queryClient.setQueryData(['user-system'], (old) => { if (!old || !newProfiles) return old; return { ...old, executors: newProfiles as unknown as UserSystemInfo['executors'], }; }); }, [queryClient] ); const setCapabilities = useCallback( (newCapabilities: Record | null) => { queryClient.setQueryData(['user-system'], (old) => { if (!old || !newCapabilities) return old; return { ...old, capabilities: newCapabilities }; }); }, [queryClient] ); // Memoize context value to prevent unnecessary re-renders const value = useMemo( () => ({ system: { appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, }, appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, updateConfig, saveConfig, updateAndSaveConfig, setEnvironment, setProfiles, setCapabilities, reloadSystem, loading: isLoading, }), [ appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, updateConfig, saveConfig, updateAndSaveConfig, reloadSystem, isLoading, setEnvironment, setProfiles, setCapabilities, ] ); return ( {children} ); } ================================================ FILE: packages/local-web/src/app/providers/ThemeProvider.tsx ================================================ import React, { useEffect, useState } from 'react'; import { ThemeMode } from 'shared/types'; import { ThemeProviderContext } from '@/shared/hooks/useTheme'; type ThemeProviderProps = { children: React.ReactNode; initialTheme?: ThemeMode; }; export function ThemeProvider({ children, initialTheme = ThemeMode.SYSTEM, ...props }: ThemeProviderProps) { const [theme, setThemeState] = useState(initialTheme); // Update theme when initialTheme changes useEffect(() => { setThemeState(initialTheme); }, [initialTheme]); useEffect(() => { const root = window.document.documentElement; root.classList.remove('light', 'dark'); if (theme === ThemeMode.SYSTEM) { const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') .matches ? 'dark' : 'light'; root.classList.add(systemTheme); return; } root.classList.add(theme.toLowerCase()); }, [theme]); const setTheme = (newTheme: ThemeMode) => { setThemeState(newTheme); }; const value = { theme, setTheme, }; return ( {children} ); } ================================================ FILE: packages/local-web/src/app/router/index.ts ================================================ import { createRouter } from '@tanstack/react-router'; import { routeTree } from '@web/routeTree.gen'; export const router = createRouter({ routeTree }); declare module '@tanstack/react-router' { interface Register { router: typeof router; } } ================================================ FILE: packages/local-web/src/routeTree.gen.ts ================================================ /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' import { Route as OnboardingRouteImport } from './routes/onboarding' import { Route as AppRouteImport } from './routes/_app' import { Route as IndexRouteImport } from './routes/index' import { Route as OnboardingSignInRouteImport } from './routes/onboarding_.sign-in' import { Route as AppWorkspacesRouteImport } from './routes/_app.workspaces' import { Route as AppNotificationsRouteImport } from './routes/_app.notifications' import { Route as AppMigrateRouteImport } from './routes/_app.migrate' import { Route as WorkspacesWorkspaceIdVscodeRouteImport } from './routes/workspaces.$workspaceId.vscode' import { Route as AppWorkspacesElectricTestRouteImport } from './routes/_app.workspaces_.electric-test' import { Route as AppWorkspacesCreateRouteImport } from './routes/_app.workspaces_.create' import { Route as AppWorkspacesWorkspaceIdRouteImport } from './routes/_app.workspaces_.$workspaceId' import { Route as AppProjectsProjectIdRouteImport } from './routes/_app.projects.$projectId' import { Route as AppProjectsProjectIdIssuesIssueIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId' import { Route as AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport } from './routes/_app.projects.$projectId_.workspaces.create.$draftId' import { Route as AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId_.workspaces.$workspaceId' import { Route as AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport } from './routes/_app.projects.$projectId_.issues.$issueId_.workspaces.create.$draftId' const OnboardingRoute = OnboardingRouteImport.update({ id: '/onboarding', path: '/onboarding', getParentRoute: () => rootRouteImport, } as any) const AppRoute = AppRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) const OnboardingSignInRoute = OnboardingSignInRouteImport.update({ id: '/onboarding_/sign-in', path: '/onboarding/sign-in', getParentRoute: () => rootRouteImport, } as any) const AppWorkspacesRoute = AppWorkspacesRouteImport.update({ id: '/workspaces', path: '/workspaces', getParentRoute: () => AppRoute, } as any) const AppNotificationsRoute = AppNotificationsRouteImport.update({ id: '/notifications', path: '/notifications', getParentRoute: () => AppRoute, } as any) const AppMigrateRoute = AppMigrateRouteImport.update({ id: '/migrate', path: '/migrate', getParentRoute: () => AppRoute, } as any) const WorkspacesWorkspaceIdVscodeRoute = WorkspacesWorkspaceIdVscodeRouteImport.update({ id: '/workspaces/$workspaceId/vscode', path: '/workspaces/$workspaceId/vscode', getParentRoute: () => rootRouteImport, } as any) const AppWorkspacesElectricTestRoute = AppWorkspacesElectricTestRouteImport.update({ id: '/workspaces_/electric-test', path: '/workspaces/electric-test', getParentRoute: () => AppRoute, } as any) const AppWorkspacesCreateRoute = AppWorkspacesCreateRouteImport.update({ id: '/workspaces_/create', path: '/workspaces/create', getParentRoute: () => AppRoute, } as any) const AppWorkspacesWorkspaceIdRoute = AppWorkspacesWorkspaceIdRouteImport.update({ id: '/workspaces_/$workspaceId', path: '/workspaces/$workspaceId', getParentRoute: () => AppRoute, } as any) const AppProjectsProjectIdRoute = AppProjectsProjectIdRouteImport.update({ id: '/projects/$projectId', path: '/projects/$projectId', getParentRoute: () => AppRoute, } as any) const AppProjectsProjectIdIssuesIssueIdRoute = AppProjectsProjectIdIssuesIssueIdRouteImport.update({ id: '/projects/$projectId_/issues/$issueId', path: '/projects/$projectId/issues/$issueId', getParentRoute: () => AppRoute, } as any) const AppProjectsProjectIdWorkspacesCreateDraftIdRoute = AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport.update({ id: '/projects/$projectId_/workspaces/create/$draftId', path: '/projects/$projectId/workspaces/create/$draftId', getParentRoute: () => AppRoute, } as any) const AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute = AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport.update({ id: '/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId', path: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId', getParentRoute: () => AppRoute, } as any) const AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute = AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport.update({ id: '/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId', path: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId', getParentRoute: () => AppRoute, } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/onboarding': typeof OnboardingRoute '/migrate': typeof AppMigrateRoute '/notifications': typeof AppNotificationsRoute '/workspaces': typeof AppWorkspacesRoute '/onboarding/sign-in': typeof OnboardingSignInRoute '/projects/$projectId': typeof AppProjectsProjectIdRoute '/workspaces/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute '/workspaces/create': typeof AppWorkspacesCreateRoute '/workspaces/electric-test': typeof AppWorkspacesElectricTestRoute '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute '/projects/$projectId/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute '/projects/$projectId/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute '/projects/$projectId/issues/$issueId/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute '/projects/$projectId/issues/$issueId/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/onboarding': typeof OnboardingRoute '/migrate': typeof AppMigrateRoute '/notifications': typeof AppNotificationsRoute '/workspaces': typeof AppWorkspacesRoute '/onboarding/sign-in': typeof OnboardingSignInRoute '/projects/$projectId': typeof AppProjectsProjectIdRoute '/workspaces/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute '/workspaces/create': typeof AppWorkspacesCreateRoute '/workspaces/electric-test': typeof AppWorkspacesElectricTestRoute '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute '/projects/$projectId/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute '/projects/$projectId/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute '/projects/$projectId/issues/$issueId/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute '/projects/$projectId/issues/$issueId/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_app': typeof AppRouteWithChildren '/onboarding': typeof OnboardingRoute '/_app/migrate': typeof AppMigrateRoute '/_app/notifications': typeof AppNotificationsRoute '/_app/workspaces': typeof AppWorkspacesRoute '/onboarding_/sign-in': typeof OnboardingSignInRoute '/_app/projects/$projectId': typeof AppProjectsProjectIdRoute '/_app/workspaces_/$workspaceId': typeof AppWorkspacesWorkspaceIdRoute '/_app/workspaces_/create': typeof AppWorkspacesCreateRoute '/_app/workspaces_/electric-test': typeof AppWorkspacesElectricTestRoute '/workspaces/$workspaceId/vscode': typeof WorkspacesWorkspaceIdVscodeRoute '/_app/projects/$projectId_/issues/$issueId': typeof AppProjectsProjectIdIssuesIssueIdRoute '/_app/projects/$projectId_/workspaces/create/$draftId': typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/onboarding' | '/migrate' | '/notifications' | '/workspaces' | '/onboarding/sign-in' | '/projects/$projectId' | '/workspaces/$workspaceId' | '/workspaces/create' | '/workspaces/electric-test' | '/workspaces/$workspaceId/vscode' | '/projects/$projectId/issues/$issueId' | '/projects/$projectId/workspaces/create/$draftId' | '/projects/$projectId/issues/$issueId/workspaces/$workspaceId' | '/projects/$projectId/issues/$issueId/workspaces/create/$draftId' fileRoutesByTo: FileRoutesByTo to: | '/' | '/onboarding' | '/migrate' | '/notifications' | '/workspaces' | '/onboarding/sign-in' | '/projects/$projectId' | '/workspaces/$workspaceId' | '/workspaces/create' | '/workspaces/electric-test' | '/workspaces/$workspaceId/vscode' | '/projects/$projectId/issues/$issueId' | '/projects/$projectId/workspaces/create/$draftId' | '/projects/$projectId/issues/$issueId/workspaces/$workspaceId' | '/projects/$projectId/issues/$issueId/workspaces/create/$draftId' id: | '__root__' | '/' | '/_app' | '/onboarding' | '/_app/migrate' | '/_app/notifications' | '/_app/workspaces' | '/onboarding_/sign-in' | '/_app/projects/$projectId' | '/_app/workspaces_/$workspaceId' | '/_app/workspaces_/create' | '/_app/workspaces_/electric-test' | '/workspaces/$workspaceId/vscode' | '/_app/projects/$projectId_/issues/$issueId' | '/_app/projects/$projectId_/workspaces/create/$draftId' | '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId' | '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AppRoute: typeof AppRouteWithChildren OnboardingRoute: typeof OnboardingRoute OnboardingSignInRoute: typeof OnboardingSignInRoute WorkspacesWorkspaceIdVscodeRoute: typeof WorkspacesWorkspaceIdVscodeRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/onboarding': { id: '/onboarding' path: '/onboarding' fullPath: '/onboarding' preLoaderRoute: typeof OnboardingRouteImport parentRoute: typeof rootRouteImport } '/_app': { id: '/_app' path: '' fullPath: '/' preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } '/': { id: '/' path: '/' fullPath: '/' preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/onboarding_/sign-in': { id: '/onboarding_/sign-in' path: '/onboarding/sign-in' fullPath: '/onboarding/sign-in' preLoaderRoute: typeof OnboardingSignInRouteImport parentRoute: typeof rootRouteImport } '/_app/workspaces': { id: '/_app/workspaces' path: '/workspaces' fullPath: '/workspaces' preLoaderRoute: typeof AppWorkspacesRouteImport parentRoute: typeof AppRoute } '/_app/notifications': { id: '/_app/notifications' path: '/notifications' fullPath: '/notifications' preLoaderRoute: typeof AppNotificationsRouteImport parentRoute: typeof AppRoute } '/_app/migrate': { id: '/_app/migrate' path: '/migrate' fullPath: '/migrate' preLoaderRoute: typeof AppMigrateRouteImport parentRoute: typeof AppRoute } '/workspaces/$workspaceId/vscode': { id: '/workspaces/$workspaceId/vscode' path: '/workspaces/$workspaceId/vscode' fullPath: '/workspaces/$workspaceId/vscode' preLoaderRoute: typeof WorkspacesWorkspaceIdVscodeRouteImport parentRoute: typeof rootRouteImport } '/_app/workspaces_/electric-test': { id: '/_app/workspaces_/electric-test' path: '/workspaces/electric-test' fullPath: '/workspaces/electric-test' preLoaderRoute: typeof AppWorkspacesElectricTestRouteImport parentRoute: typeof AppRoute } '/_app/workspaces_/create': { id: '/_app/workspaces_/create' path: '/workspaces/create' fullPath: '/workspaces/create' preLoaderRoute: typeof AppWorkspacesCreateRouteImport parentRoute: typeof AppRoute } '/_app/workspaces_/$workspaceId': { id: '/_app/workspaces_/$workspaceId' path: '/workspaces/$workspaceId' fullPath: '/workspaces/$workspaceId' preLoaderRoute: typeof AppWorkspacesWorkspaceIdRouteImport parentRoute: typeof AppRoute } '/_app/projects/$projectId': { id: '/_app/projects/$projectId' path: '/projects/$projectId' fullPath: '/projects/$projectId' preLoaderRoute: typeof AppProjectsProjectIdRouteImport parentRoute: typeof AppRoute } '/_app/projects/$projectId_/issues/$issueId': { id: '/_app/projects/$projectId_/issues/$issueId' path: '/projects/$projectId/issues/$issueId' fullPath: '/projects/$projectId/issues/$issueId' preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdRouteImport parentRoute: typeof AppRoute } '/_app/projects/$projectId_/workspaces/create/$draftId': { id: '/_app/projects/$projectId_/workspaces/create/$draftId' path: '/projects/$projectId/workspaces/create/$draftId' fullPath: '/projects/$projectId/workspaces/create/$draftId' preLoaderRoute: typeof AppProjectsProjectIdWorkspacesCreateDraftIdRouteImport parentRoute: typeof AppRoute } '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId': { id: '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId' path: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId' fullPath: '/projects/$projectId/issues/$issueId/workspaces/$workspaceId' preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRouteImport parentRoute: typeof AppRoute } '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId': { id: '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId' path: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId' fullPath: '/projects/$projectId/issues/$issueId/workspaces/create/$draftId' preLoaderRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRouteImport parentRoute: typeof AppRoute } } } interface AppRouteChildren { AppMigrateRoute: typeof AppMigrateRoute AppNotificationsRoute: typeof AppNotificationsRoute AppWorkspacesRoute: typeof AppWorkspacesRoute AppProjectsProjectIdRoute: typeof AppProjectsProjectIdRoute AppWorkspacesWorkspaceIdRoute: typeof AppWorkspacesWorkspaceIdRoute AppWorkspacesCreateRoute: typeof AppWorkspacesCreateRoute AppWorkspacesElectricTestRoute: typeof AppWorkspacesElectricTestRoute AppProjectsProjectIdIssuesIssueIdRoute: typeof AppProjectsProjectIdIssuesIssueIdRoute AppProjectsProjectIdWorkspacesCreateDraftIdRoute: typeof AppProjectsProjectIdWorkspacesCreateDraftIdRoute AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute: typeof AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute } const AppRouteChildren: AppRouteChildren = { AppMigrateRoute: AppMigrateRoute, AppNotificationsRoute: AppNotificationsRoute, AppWorkspacesRoute: AppWorkspacesRoute, AppProjectsProjectIdRoute: AppProjectsProjectIdRoute, AppWorkspacesWorkspaceIdRoute: AppWorkspacesWorkspaceIdRoute, AppWorkspacesCreateRoute: AppWorkspacesCreateRoute, AppWorkspacesElectricTestRoute: AppWorkspacesElectricTestRoute, AppProjectsProjectIdIssuesIssueIdRoute: AppProjectsProjectIdIssuesIssueIdRoute, AppProjectsProjectIdWorkspacesCreateDraftIdRoute: AppProjectsProjectIdWorkspacesCreateDraftIdRoute, AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute: AppProjectsProjectIdIssuesIssueIdWorkspacesWorkspaceIdRoute, AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute: AppProjectsProjectIdIssuesIssueIdWorkspacesCreateDraftIdRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AppRoute: AppRouteWithChildren, OnboardingRoute: OnboardingRoute, OnboardingSignInRoute: OnboardingSignInRoute, WorkspacesWorkspaceIdVscodeRoute: WorkspacesWorkspaceIdVscodeRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() ================================================ FILE: packages/local-web/src/routes/__root.tsx ================================================ import { useEffect, type ReactNode } from 'react'; import { Outlet, createRootRoute, useLocation } from '@tanstack/react-router'; import { I18nextProvider } from 'react-i18next'; import { usePostHog } from 'posthog-js/react'; import { Provider as NiceModalProvider } from '@ebay/nice-modal-react'; import { ThemeMode } from 'shared/types'; import i18n from '@/i18n'; import { useUserSystem } from '@/shared/hooks/useUserSystem'; import { ThemeProvider } from '@web/app/providers/ThemeProvider'; import { useUiPreferencesScratch } from '@/shared/hooks/useUiPreferencesScratch'; import { ReleaseNotesDialog } from '@/shared/dialogs/global/ReleaseNotesDialog'; import { WorkspaceProvider } from '@/shared/providers/WorkspaceProvider'; import { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext'; import { ExecutionProcessesProvider } from '@/shared/providers/ExecutionProcessesProvider'; import { LogsPanelProvider } from '@/shared/providers/LogsPanelProvider'; import { ActionsProvider } from '@/shared/providers/ActionsProvider'; import { UserProvider } from '@/shared/providers/remote/UserProvider'; import '@/app/styles/new/index.css'; function ExecutionProcessesProviderWrapper({ children, }: { children: ReactNode; }) { const { selectedSessionId } = useWorkspaceContext(); return ( {children} ); } function RootRouteComponent() { const { config, analyticsUserId, updateAndSaveConfig } = useUserSystem(); const posthog = usePostHog(); const location = useLocation(); useUiPreferencesScratch(); useEffect(() => { if (!posthog || !analyticsUserId) return; if (config?.analytics_enabled) { posthog.opt_in_capturing(); posthog.identify(analyticsUserId); console.log('[Analytics] Analytics enabled and user identified'); } else { posthog.opt_out_capturing(); console.log('[Analytics] Analytics disabled by user preference'); } }, [config?.analytics_enabled, analyticsUserId, posthog]); useEffect(() => { if (!config || !config.remote_onboarding_acknowledged) return; const pathname = location.pathname; if (pathname.startsWith('/onboarding') || pathname.startsWith('/migrate')) { return; } let cancelled = false; const showReleaseNotes = async () => { if (config.show_release_notes) { await ReleaseNotesDialog.show(); if (!cancelled) { await updateAndSaveConfig({ show_release_notes: false }); } ReleaseNotesDialog.hide(); } }; void showReleaseNotes(); return () => { cancelled = true; }; }, [config, updateAndSaveConfig, location.pathname]); return ( ); } export const Route = createRootRoute({ component: RootRouteComponent, }); ================================================ FILE: packages/local-web/src/routes/_app.migrate.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { MigratePage } from '@/pages/migrate/MigratePage'; export const Route = createFileRoute('/_app/migrate')({ component: MigratePage, }); ================================================ FILE: packages/local-web/src/routes/_app.notifications.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { NotificationsPage } from '@/pages/workspaces/NotificationsPage'; export const Route = createFileRoute('/_app/notifications')({ component: NotificationsPage, }); ================================================ FILE: packages/local-web/src/routes/_app.projects.$projectId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban'; import { projectSearchValidator } from '@vibe/web-core/project-search'; export const Route = createFileRoute('/_app/projects/$projectId')({ validateSearch: projectSearchValidator, component: LocalProjectKanban, }); ================================================ FILE: packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban'; import { projectSearchValidator } from '@vibe/web-core/project-search'; export const Route = createFileRoute( '/_app/projects/$projectId_/issues/$issueId' )({ validateSearch: projectSearchValidator, component: LocalProjectKanban, }); ================================================ FILE: packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId_.workspaces.$workspaceId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban'; import { projectSearchValidator } from '@vibe/web-core/project-search'; export const Route = createFileRoute( '/_app/projects/$projectId_/issues/$issueId_/workspaces/$workspaceId' )({ validateSearch: projectSearchValidator, component: LocalProjectKanban, }); ================================================ FILE: packages/local-web/src/routes/_app.projects.$projectId_.issues.$issueId_.workspaces.create.$draftId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban'; import { projectSearchValidator } from '@vibe/web-core/project-search'; export const Route = createFileRoute( '/_app/projects/$projectId_/issues/$issueId_/workspaces/create/$draftId' )({ validateSearch: projectSearchValidator, component: LocalProjectKanban, }); ================================================ FILE: packages/local-web/src/routes/_app.projects.$projectId_.workspaces.create.$draftId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LocalProjectKanban } from '@/pages/kanban/LocalProjectKanban'; import { projectSearchValidator } from '@vibe/web-core/project-search'; export const Route = createFileRoute( '/_app/projects/$projectId_/workspaces/create/$draftId' )({ validateSearch: projectSearchValidator, component: LocalProjectKanban, }); ================================================ FILE: packages/local-web/src/routes/_app.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { SequenceTrackerProvider } from '@/shared/keyboard/SequenceTracker'; import { SequenceIndicator } from '@/shared/keyboard/SequenceIndicator'; import { useWorkspaceShortcuts } from '@/shared/keyboard/useWorkspaceShortcuts'; import { useIssueShortcuts } from '@/shared/keyboard/useIssueShortcuts'; import { useKeyShowHelp, Scope } from '@/shared/keyboard'; import { KeyboardShortcutsDialog } from '@/shared/dialogs/shared/KeyboardShortcutsDialog'; import { TerminalProvider } from '@/shared/providers/TerminalProvider'; import { SharedAppLayout } from '@/shared/components/ui-new/containers/SharedAppLayout'; function KeyboardShortcutsHandler() { useKeyShowHelp( () => { KeyboardShortcutsDialog.show(); }, { scope: Scope.GLOBAL } ); useWorkspaceShortcuts(); useIssueShortcuts(); return null; } function AppLayoutRouteComponent() { return ( ); } export const Route = createFileRoute('/_app')({ component: AppLayoutRouteComponent, }); ================================================ FILE: packages/local-web/src/routes/_app.workspaces.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { WorkspacesLanding } from '@/pages/workspaces/WorkspacesLanding'; export const Route = createFileRoute('/_app/workspaces')({ component: WorkspacesLanding, }); ================================================ FILE: packages/local-web/src/routes/_app.workspaces_.$workspaceId.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { Workspaces } from '@/pages/workspaces/Workspaces'; export const Route = createFileRoute('/_app/workspaces_/$workspaceId')({ component: Workspaces, }); ================================================ FILE: packages/local-web/src/routes/_app.workspaces_.create.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { Workspaces } from '@/pages/workspaces/Workspaces'; export const Route = createFileRoute('/_app/workspaces_/create')({ component: Workspaces, }); ================================================ FILE: packages/local-web/src/routes/_app.workspaces_.electric-test.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { ElectricTestPage } from '@/pages/workspaces/ElectricTestPage'; export const Route = createFileRoute('/_app/workspaces_/electric-test')({ component: ElectricTestPage, }); ================================================ FILE: packages/local-web/src/routes/index.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { RootRedirectPage } from '@/pages/root/RootRedirectPage'; function RootRedirectRouteComponent() { return ; } export const Route = createFileRoute('/')({ component: RootRedirectRouteComponent, }); ================================================ FILE: packages/local-web/src/routes/onboarding.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { LandingPage } from '@/features/onboarding/ui/LandingPage'; function OnboardingLandingRouteComponent() { return ; } export const Route = createFileRoute('/onboarding')({ component: OnboardingLandingRouteComponent, }); ================================================ FILE: packages/local-web/src/routes/onboarding_.sign-in.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { OnboardingSignInPage } from '@/features/onboarding/ui/OnboardingSignInPage'; function OnboardingSignInRouteComponent() { return ; } export const Route = createFileRoute('/onboarding_/sign-in')({ component: OnboardingSignInRouteComponent, }); ================================================ FILE: packages/local-web/src/routes/workspaces.$workspaceId.vscode.tsx ================================================ import { createFileRoute } from '@tanstack/react-router'; import { TerminalProvider } from '@/shared/providers/TerminalProvider'; import { VSCodeWorkspacePage } from '@/pages/workspaces/VSCodeWorkspacePage'; function VSCodeWorkspaceRouteComponent() { return ( ); } export const Route = createFileRoute('/workspaces/$workspaceId/vscode')({ component: VSCodeWorkspaceRouteComponent, }); ================================================ FILE: packages/local-web/src/shared/types/virtual-executor-schemas.d.ts ================================================ declare module 'virtual:executor-schemas' { import type { RJSFSchema } from '@rjsf/utils'; import type { BaseCodingAgent } from 'shared/types'; const schemas: Record; export { schemas }; export default schemas; } ================================================ FILE: packages/local-web/src/vite-env.d.ts ================================================ /// declare const __APP_VERSION__: string; ================================================ FILE: packages/local-web/tailwind.new.config.js ================================================ /** @type {import('tailwindcss').Config} */ const sizes = { '2xs': 0.5, xs: 0.75, sm: 0.875, base: 1, lg: 1.125, xl: 1.25, } const lineHeightMultiplier = 1.5; const radiusMultiplier = 0.25; const iconMultiplier = 1.25; const chatMaxWidth = '48rem'; function getSize(sizeLabel, multiplier = 1) { return sizes[sizeLabel] * multiplier + "rem"; } module.exports = { darkMode: ["class"], important: false, content: [ './pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}', '../web-core/src/**/*.{ts,tsx}', '../remote-web/src/**/*.{ts,tsx}', '../ui/src/**/*.{ts,tsx}', "node_modules/@rjsf/shadcn/src/**/*.{js,ts,jsx,tsx,mdx}" ], safelist: [ 'xl:hidden', 'xl:relative', 'xl:inset-auto', 'xl:z-auto', 'xl:h-full', 'xl:w-[800px]', 'xl:flex', 'xl:flex-1', 'xl:min-w-0', 'xl:overflow-y-auto', 'xl:opacity-100', 'xl:pointer-events-auto', ], prefix: "", theme: { container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { height: { 'cta': '29px', }, minHeight: { 'cta': '29px', }, width: { chat: chatMaxWidth, }, containers: { chat: chatMaxWidth, }, size: { 'icon-2xs': getSize('2xs', iconMultiplier), 'icon-xs': getSize('xs', iconMultiplier), 'icon-sm': getSize('sm', iconMultiplier), 'icon-base': getSize('base', iconMultiplier), 'icon-lg': getSize('lg', iconMultiplier), 'icon-xl': getSize('xl', iconMultiplier), 'dot': '0.3rem', // 6px - for animated indicator dots }, backgroundImage: { 'diagonal-lines': ` repeating-linear-gradient(-45deg, hsl(var(--text-low) / 0.4) 0 2px, transparent 1px 12px), linear-gradient(hsl(var(--bg-primary)), hsl(var(--bg-primary))) `, }, ringColor: { DEFAULT: 'hsl(var(--brand))', }, fontSize: { xs: [getSize('xs'), { lineHeight: getSize('xs', lineHeightMultiplier) }], // 8px sm: [getSize('sm'), { lineHeight: getSize('sm', lineHeightMultiplier) }], // 10px base: [getSize('base'), { lineHeight: getSize('base', lineHeightMultiplier) }], // 12px (base) lg: [getSize('lg'), { lineHeight: getSize('lg', lineHeightMultiplier) }], // 14px xl: [getSize('xl'), { lineHeight: getSize('xl', lineHeightMultiplier) }], // 16px cta: [getSize('base'), { lineHeight: getSize('base') }], // 16px }, spacing: { 'half': getSize('base', 0.25), 'base': getSize('base', 0.5), 'plusfifty': getSize('base', 0.75), 'double': getSize('base', 1), }, colors: { // Text colors: text-high, text-normal, text-low high: "hsl(var(--text-high))", normal: "hsl(var(--text-normal))", low: "hsl(var(--text-low))", // Background colors: bg-primary, bg-secondary, bg-panel primary: "hsl(var(--bg-primary))", secondary: "hsl(var(--bg-secondary))", panel: "hsl(var(--bg-panel))", // Accent colors brand: "hsl(var(--brand))", 'brand-hover': "hsl(var(--brand-hover))", 'brand-secondary': "hsl(var(--brand-secondary))", error: "hsl(var(--error))", success: "hsl(var(--success))", merged: "hsl(var(--merged))", // Text on accent 'on-brand': "hsl(var(--text-on-brand))", // shadcn-style colors (used by @apply in CSS base layer) background: "hsl(var(--bg-primary))", foreground: "hsl(var(--text-normal))", border: "hsl(var(--border))", }, borderColor: { DEFAULT: "hsl(var(--border))", border: "hsl(var(--border))", }, borderRadius: { lg: getSize('lg', radiusMultiplier), md: getSize('sm', radiusMultiplier), sm: getSize('xs', radiusMultiplier), }, borderWidth: { base: getSize('base'), half: getSize('base', 0.5), }, fontFamily: { 'ibm-plex-sans': ['"IBM Plex Sans"', '"Noto Emoji"', 'sans-serif'], 'ibm-plex-mono': ['"IBM Plex Mono"', 'monospace'], }, keyframes: { "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, pill: { '0%': { opacity: '0' }, '10%': { opacity: '1' }, '80%': { opacity: '1' }, '100%': { opacity: '0' }, }, 'running-dot': { '0%, 100%': { opacity: '0.3' }, '50%': { opacity: '1' }, }, 'border-flash': { '0%': { backgroundPosition: '-200% 0' }, '100%': { backgroundPosition: '200% 0' }, }, shake: { '0%, 100%': { transform: 'translateX(0)' }, '10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-2px)' }, '20%, 40%, 60%, 80%': { transform: 'translateX(2px)' }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", pill: 'pill 2s ease-in-out forwards', 'running-dot-1': 'running-dot 1.4s ease-in-out infinite', 'running-dot-2': 'running-dot 1.4s ease-in-out 0.2s infinite', 'running-dot-3': 'running-dot 1.4s ease-in-out 0.4s infinite', 'border-flash': 'border-flash 2s linear infinite', shake: 'shake 0.3s ease-in-out', }, }, }, plugins: [require("tailwindcss-animate"), require("@tailwindcss/container-queries"), require("tailwind-scrollbar")({ nocompatible: true })], } ================================================ FILE: packages/local-web/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@web/*": ["./src/*"], "@/*": ["../web-core/src/*"], "shared/*": ["../../shared/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: packages/local-web/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: packages/local-web/vite.config.ts ================================================ // vite.config.ts import { sentryVitePlugin } from "@sentry/vite-plugin"; import { createLogger, defineConfig, Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import path from "path"; import fs from "fs"; import pkg from "./package.json"; function createFilteredLogger() { const logger = createLogger(); const originalError = logger.error.bind(logger); let lastRestartLog = 0; const DEBOUNCE_MS = 2000; logger.error = (msg, options) => { const isProxyError = msg.includes("ws proxy socket error") || msg.includes("ws proxy error:") || msg.includes("http proxy error:"); if (isProxyError) { const now = Date.now(); if (now - lastRestartLog > DEBOUNCE_MS) { logger.warn("Proxy connection closed, auto-reconnecting..."); lastRestartLog = now; } return; } originalError(msg, options); }; return logger; } function executorSchemasPlugin(): Plugin { const VIRTUAL_ID = 'virtual:executor-schemas'; const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_ID; return { name: 'executor-schemas-plugin', resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID; // keep it virtual return null; }, load(id) { if (id !== RESOLVED_VIRTUAL_ID) return null; const schemasDir = path.resolve(__dirname, '../../shared/schemas'); const files = fs.existsSync(schemasDir) ? fs.readdirSync(schemasDir).filter((f) => f.endsWith('.json')) : []; const imports: string[] = []; const entries: string[] = []; files.forEach((file, i) => { const varName = `__schema_${i}`; const importPath = `shared/schemas/${file}`; // uses your alias const key = file.replace(/\.json$/, '').toUpperCase(); // claude_code -> CLAUDE_CODE imports.push(`import ${varName} from "${importPath}";`); entries.push(` "${key}": ${varName}`); }); // IMPORTANT: pure JS (no TS types), and quote keys. const code = ` ${imports.join('\n')} export const schemas = { ${entries.join(',\n')} }; export default schemas; `; return code; }, }; } export default defineConfig({ customLogger: createFilteredLogger(), publicDir: path.resolve(__dirname, '../public'), define: { __APP_VERSION__: JSON.stringify(pkg.version), }, plugins: [ tanstackRouter({ target: "react", autoCodeSplitting: false, }), react({ babel: { plugins: [ [ 'babel-plugin-react-compiler', { target: '18', sources: [ path.resolve(__dirname, 'src'), path.resolve(__dirname, '../web-core/src'), ], environment: { enableResetCacheOnSourceFileChanges: true, }, }, ], ], }, }), sentryVitePlugin({ org: 'bloop-ai', project: 'vibe-kanban' }), executorSchemasPlugin(), ], resolve: { alias: [ { find: '@web', replacement: path.resolve(__dirname, 'src'), }, { find: /^@\//, replacement: `${path.resolve(__dirname, '../web-core/src')}/`, }, { find: 'shared', replacement: path.resolve(__dirname, '../../shared'), }, ], }, server: { port: parseInt(process.env.FRONTEND_PORT || '3000'), proxy: { '/api': { target: `http://localhost:${process.env.BACKEND_PORT || '3001'}`, changeOrigin: true, ws: true, }, }, fs: { allow: [path.resolve(__dirname, '.'), path.resolve(__dirname, '../..')], }, open: process.env.VITE_OPEN === 'true', allowedHosts: [ '.trycloudflare.com', // allow all cloudflared tunnels ], }, optimizeDeps: { exclude: ['wa-sqlite'], }, build: { sourcemap: true }, }); ================================================ FILE: packages/public/robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: packages/public/site.webmanifest ================================================ { "name": "Vibe Kanban", "short_name": "VK", "icons": [ { "src": "/favicon-vk-light.svg", "sizes": "any", "purpose": "any", "type": "image/svg+xml" }, { "src": "/favicon-vk-light-maskable.svg", "sizes": "any", "purpose": "maskable", "type": "image/svg+xml" }, { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } ], "theme_color": "#f2f2f2", "background_color": "#f2f2f2", "display": "standalone" } ================================================ FILE: packages/remote-web/.prettierignore ================================================ src/routeTree.gen.ts ================================================ FILE: packages/remote-web/index.html ================================================ Vibe Kanban Remote
================================================ FILE: packages/remote-web/package.json ================================================ { "name": "@vibe/remote-web", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "check": "tsc --noEmit", "preview": "vite preview", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "migrate:structure": "node ../../scripts/migrate-remote-web-structure.mjs --apply", "migrate:structure:dry-run": "node ../../scripts/migrate-remote-web-structure.mjs" }, "dependencies": { "@ebay/nice-modal-react": "^1.2.13", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.85.5", "@tanstack/react-router": "^1.161.1", "@tanstack/zod-adapter": "^1.161.1", "@vibe/ui": "workspace:*", "@vibe/web-core": "workspace:*", "clsx": "^2.1.1", "posthog-js": "^1.283.0", "prettier": "^3.6.1", "react": "^18.2.0", "react-hotkeys-hook": "^5.1.0", "react-compiler-runtime": "^1.0.0", "react-dom": "^18.2.0", "simple-icons": "^15.16.0", "tailwind-merge": "^2.6.0", "zod": "^3.25.76", "zustand": "^4.5.7" }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", "@tanstack/router-plugin": "^1.161.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "babel-plugin-react-compiler": "^1.0.0", "postcss": "^8.4.32", "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.2", "vite": "^7.3.1" } } ================================================ FILE: packages/remote-web/postcss.config.cjs ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: packages/remote-web/src/app/entry/App.tsx ================================================ import { RouterProvider } from "@tanstack/react-router"; import { HotkeysProvider } from "react-hotkeys-hook"; import { router } from "@remote/app/router"; import { AppRuntimeProvider } from "@/shared/hooks/useAppRuntime"; export function AppRouter() { return ( ); } ================================================ FILE: packages/remote-web/src/app/entry/Bootstrap.tsx ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { QueryClientProvider } from "@tanstack/react-query"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; import { AppRouter } from "@remote/app/entry/App"; import { RemoteAuthProvider } from "@remote/app/providers/RemoteAuthProvider"; import { getIdentity } from "@remote/shared/lib/api"; import { getToken, triggerRefresh } from "@remote/shared/lib/auth/tokenManager"; import "@remote/app/styles/index.css"; import "@/i18n"; import { configureAuthRuntime } from "@/shared/lib/auth/runtime"; import { setRemoteApiBase } from "@/shared/lib/remoteApi"; import { setRelayApiBase } from "@/shared/lib/relayBackendApi"; import { setLocalApiTransport } from "@/shared/lib/localApiTransport"; import "@/shared/types/modals"; import { queryClient } from "@/shared/lib/queryClient"; import { openLocalApiWebSocketViaRelay, requestLocalApiViaRelay, } from "@remote/shared/lib/relayHostApi"; if (import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, }); } setRemoteApiBase(import.meta.env.VITE_API_BASE_URL || window.location.origin); setRelayApiBase( import.meta.env.VITE_RELAY_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || window.location.origin, ); setLocalApiTransport({ request: requestLocalApiViaRelay, openWebSocket: openLocalApiWebSocketViaRelay, }); configureAuthRuntime({ getToken, triggerRefresh, registerShape: () => () => {}, getCurrentUser: async () => { const identity = await getIdentity(); return { user_id: identity.user_id }; }, }); ReactDOM.createRoot(document.getElementById("root")!).render( , ); ================================================ FILE: packages/remote-web/src/app/layout/RemoteAppBarUserPopoverContainer.tsx ================================================ import { useCallback, useState } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import type { OrganizationWithRole } from "shared/types"; import { AppBarUserPopover } from "@vibe/ui/components/AppBarUserPopover"; import { logout } from "@remote/shared/lib/api"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { useUserSystem } from "@/shared/hooks/useUserSystem"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; interface RemoteAppBarUserPopoverContainerProps { organizations: OrganizationWithRole[]; selectedOrgId: string; onOrgSelect: (orgId: string) => void; onCreateOrg: () => void; } function toNextPath({ pathname, searchStr, hash, }: Pick, "pathname" | "searchStr" | "hash">) { return `${pathname}${searchStr}${hash}`; } export function RemoteAppBarUserPopoverContainer({ organizations, selectedOrgId, onOrgSelect, onCreateOrg, }: RemoteAppBarUserPopoverContainerProps) { const { isSignedIn } = useAuth(); const { loginStatus } = useUserSystem(); // Extract avatar URL from first provider (matches local-web behavior) const avatarUrl = loginStatus?.status === "loggedin" ? (loginStatus.profile.providers[0]?.avatar_url ?? null) : null; const navigate = useNavigate(); const location = useLocation(); const [open, setOpen] = useState(false); const [avatarError, setAvatarError] = useState(false); const handleSignIn = useCallback(() => { const next = toNextPath(location); navigate({ to: "/account", search: next !== "/" ? { next } : undefined, }); }, [location, navigate]); const handleLogout = useCallback(async () => { try { await logout(); } catch (error) { console.error("Failed to log out in remote web:", error); } navigate({ to: "/account", replace: true, }); }, [navigate]); const handleOrgSettings = useCallback( async (orgId: string) => { onOrgSelect(orgId); await SettingsDialog.show({ initialSection: "organizations", initialState: { organizationId: orgId }, sections: REMOTE_SETTINGS_SECTIONS, }); }, [onOrgSelect], ); const handleSettings = useCallback(async () => { setOpen(false); await SettingsDialog.show({ sections: REMOTE_SETTINGS_SECTIONS, }); }, []); return ( { void handleOrgSettings(orgId); }} onSignIn={handleSignIn} onLogout={() => { void handleLogout(); }} onAvatarError={() => setAvatarError(true)} onSettings={() => { void handleSettings(); }} /> ); } ================================================ FILE: packages/remote-web/src/app/layout/RemoteAppShell.tsx ================================================ import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { useQuery } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { siDiscord, siGithub } from "simple-icons"; import { AppBar, type AppBarHostStatus } from "@vibe/ui/components/AppBar"; import { XIcon, PlusIcon, HouseIcon, KanbanIcon } from "@phosphor-icons/react"; import { MobileDrawer } from "@vibe/ui/components/MobileDrawer"; import type { Project } from "shared/remote-types"; import { useIsMobile } from "@/shared/hooks/useIsMobile"; import { cn } from "@/shared/lib/utils"; import { useUserOrganizations } from "@/shared/hooks/useUserOrganizations"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { useOrganizationStore } from "@/shared/stores/useOrganizationStore"; import { useDiscordOnlineCount } from "@/shared/hooks/useDiscordOnlineCount"; import { useGitHubStars } from "@/shared/hooks/useGitHubStars"; import { AppBarNotificationBellContainer } from "@/pages/workspaces/AppBarNotificationBellContainer"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { CommandBarDialog } from "@/shared/dialogs/command-bar/CommandBarDialog"; import { useCommandBarShortcut } from "@/shared/hooks/useCommandBarShortcut"; import { listOrganizationProjects } from "@remote/shared/lib/api"; import { RemoteAppBarUserPopoverContainer } from "@remote/app/layout/RemoteAppBarUserPopoverContainer"; import { RemoteNavbarContainer } from "@remote/app/layout/RemoteNavbarContainer"; import { RemoteDesktopNavbar } from "@remote/app/layout/RemoteDesktopNavbar"; import { resolveRelayNavigationHostId, useRelayAppBarHosts, } from "@remote/shared/hooks/useRelayAppBarHosts"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; import { CreateOrganizationDialog, type CreateOrganizationResult, } from "@/shared/dialogs/org/CreateOrganizationDialog"; import { CreateRemoteProjectDialog, type CreateRemoteProjectResult, } from "@/shared/dialogs/org/CreateRemoteProjectDialog"; interface RemoteAppShellProps { children: ReactNode; } function getHostInitials(name: string): string { const trimmed = name.trim(); if (!trimmed) return "??"; const words = trimmed.split(/\s+/); if (words.length >= 2) { return (words[0][0] + words[1][0]).toUpperCase(); } return trimmed.slice(0, 2).toUpperCase(); } export function RemoteAppShell({ children }: RemoteAppShellProps) { const navigate = useNavigate(); const location = useLocation(); const { hostId: routeHostId } = useParams({ strict: false }); const { isSignedIn } = useAuth(); const isWorkspaceContextRoute = location.pathname.includes("/workspaces"); const isProjectRoute = /^\/projects\/[^/]+/.test(location.pathname); useCommandBarShortcut( () => CommandBarDialog.show(), isWorkspaceContextRoute || isProjectRoute, ); const isMobile = useIsMobile(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const { data: organizationsData } = useUserOrganizations(); const organizations = organizationsData?.organizations ?? []; const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId); const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId); useEffect(() => { if (organizations.length === 0) { return; } const hasValidSelection = selectedOrgId ? organizations.some((organization) => organization.id === selectedOrgId) : false; if (!hasValidSelection) { const firstOrg = organizations.find( (organization) => !organization.is_personal, ); setSelectedOrgId((firstOrg ?? organizations[0]).id); } }, [organizations, selectedOrgId, setSelectedOrgId]); const activeOrganizationId = useMemo(() => { if (!selectedOrgId) { return organizations[0]?.id ?? null; } const isSelectedOrgAvailable = organizations.some( (organization) => organization.id === selectedOrgId, ); if (!isSelectedOrgAvailable) { return organizations[0]?.id ?? null; } return selectedOrgId; }, [organizations, selectedOrgId]); const projectsQuery = useQuery({ queryKey: ["remote-app-shell", "projects", activeOrganizationId], queryFn: async (): Promise => { if (!activeOrganizationId) { return []; } const projects = await listOrganizationProjects(activeOrganizationId); return [...projects].sort((a, b) => a.sort_order - b.sort_order); }, enabled: isSignedIn && !!activeOrganizationId, staleTime: 30_000, }); const projects = projectsQuery.data ?? []; const isLoadingProjects = isSignedIn && !!activeOrganizationId && projectsQuery.isLoading; const { data: onlineCount } = useDiscordOnlineCount(); const { data: starCount } = useGitHubStars(); const { hosts: relayHosts } = useRelayAppBarHosts(isSignedIn); const selectedOrgName = organizations.find((organization) => organization.id === selectedOrgId) ?.name ?? null; const isWorkspacesActive = location.pathname.includes("/workspaces"); const activeHostId = routeHostId ?? null; const preferredHostId = useMemo( () => resolveRelayNavigationHostId(relayHosts, { routeHostId }), [relayHosts, routeHostId], ); const activeProjectId = useMemo(() => { const segments = location.pathname.split("/").filter(Boolean); const projectSegmentIndex = segments.indexOf("projects"); if (projectSegmentIndex === -1) { return null; } return segments[projectSegmentIndex + 1] ?? null; }, [location.pathname]); const openRelaySettings = useCallback((hostId?: string) => { void SettingsDialog.show({ initialSection: "relay", ...(hostId ? { initialState: { hostId } } : {}), sections: REMOTE_SETTINGS_SECTIONS, }); }, []); const handleWorkspacesClick = useCallback(() => { if (preferredHostId) { navigate({ to: "/hosts/$hostId/workspaces", params: { hostId: preferredHostId }, }); return; } openRelaySettings(); }, [navigate, openRelaySettings, preferredHostId]); const handleProjectClick = useCallback( (projectId: string) => { navigate({ to: "/projects/$projectId", params: { projectId }, }); }, [navigate], ); const handleCreateProject = useCallback(async () => { if (!activeOrganizationId) { return; } try { const result: CreateRemoteProjectResult = await CreateRemoteProjectDialog.show({ organizationId: activeOrganizationId, }); if (result.action === "created" && result.project) { void projectsQuery.refetch(); navigate({ to: "/projects/$projectId", params: { projectId: result.project.id, }, }); } } catch { // Dialog cancelled } }, [activeOrganizationId, navigate, projectsQuery]); const handleCreateOrg = useCallback(async () => { try { const result: CreateOrganizationResult = await CreateOrganizationDialog.show(); if (result.action === "created" && result.organizationId) { setSelectedOrgId(result.organizationId); } } catch { // Dialog cancelled } }, [setSelectedOrgId]); const handleHostClick = useCallback( (hostId: string, status: AppBarHostStatus) => { if (status === "online") { navigate({ to: "/hosts/$hostId/workspaces", params: { hostId }, }); return; } if (status !== "unpaired") { return; } openRelaySettings(hostId); }, [navigate, openRelaySettings], ); const handlePairHostClick = useCallback(() => { openRelaySettings(); }, [openRelaySettings]); const mobileUserSlot = useMemo(() => { if (!isMobile) return undefined; return ( ); }, [ isMobile, organizations, selectedOrgId, setSelectedOrgId, handleCreateOrg, ]); return (
{!isMobile && ( {}} isSavingProjectOrder={true} isWorkspacesActive={isWorkspacesActive} activeProjectId={activeProjectId} isSignedIn={isSignedIn} isLoadingProjects={isLoadingProjects} onSignIn={() => { navigate({ to: "/account" }); }} notificationBell={ isSignedIn ? : undefined } userPopover={ } starCount={starCount} onlineCount={onlineCount} githubIconPath={siGithub.path} discordIconPath={siDiscord.path} /> )} setIsDrawerOpen(false)} >
{/* Header: org name + close button */}
{selectedOrgName ?? "Organization"}
{/* Home link */} {/* Divider */}
{/* Hosts section */} {isSignedIn && relayHosts.length > 0 && ( <>

Hosts

{relayHosts.map((host) => { const isOnline = host.status === "online"; const isUnpaired = host.status === "unpaired"; const isClickable = isOnline || isUnpaired; return ( ); })}
)} {/* Link a host button */} {isSignedIn && (
)} {/* Divider */}
{/* Project list */}
{isSignedIn ? ( isLoadingProjects ? (

Loading projects…

) : ( projects.map((project) => ( )) ) ) : (

Kanban Boards

Sign in to organise your coding agents with kanban boards.

)}
{/* Create Project button */} {isSignedIn && (
)}
{isMobile && (isWorkspaceContextRoute || isProjectRoute) && ( setIsDrawerOpen(true)} mobileUserSlot={mobileUserSlot} /> )} {!isMobile && (isWorkspaceContextRoute || isProjectRoute) && ( )}
{children}
); } ================================================ FILE: packages/remote-web/src/app/layout/RemoteDesktopNavbar.tsx ================================================ import { useMemo, useCallback } from "react"; import { useLocation } from "@tanstack/react-router"; import { useWorkspaceContext } from "@/shared/hooks/useWorkspaceContext"; import { useActions } from "@/shared/hooks/useActions"; import { useSyncErrorContext } from "@/shared/hooks/useSyncErrorContext"; import { useUserOrganizations } from "@/shared/hooks/useUserOrganizations"; import { useOrganizationStore } from "@/shared/stores/useOrganizationStore"; import { Navbar, type NavbarSectionItem } from "@vibe/ui/components/Navbar"; import { NavbarActionGroups } from "@/shared/actions"; import { NavbarDivider, type ActionDefinition, type NavbarItem as ActionNavbarItem, type ActionVisibilityContext, isSpecialIcon, getActionIcon, getActionTooltip, isActionActive, isActionEnabled, isActionVisible, } from "@/shared/types/actions"; import { useActionVisibilityContext } from "@/shared/hooks/useActionVisibilityContext"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { CommandBarDialog } from "@/shared/dialogs/command-bar/CommandBarDialog"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; /** * Check if a NavbarItem is a divider */ function isDivider(item: ActionNavbarItem): item is typeof NavbarDivider { return "type" in item && item.type === "divider"; } /** * Filter navbar items by visibility, keeping dividers but removing them * if they would appear at the start, end, or consecutively. */ function filterNavbarItems( items: readonly ActionNavbarItem[], ctx: ActionVisibilityContext, ): ActionNavbarItem[] { const filtered = items.filter((item) => { if (isDivider(item)) return true; if (!isActionVisible(item, ctx)) return false; return !isSpecialIcon(getActionIcon(item, ctx)); }); const result: ActionNavbarItem[] = []; for (const item of filtered) { if (isDivider(item)) { if (result.length > 0 && !isDivider(result[result.length - 1])) { result.push(item); } } else { result.push(item); } } if (result.length > 0 && isDivider(result[result.length - 1])) { result.pop(); } return result; } function toNavbarSectionItems( items: readonly ActionNavbarItem[], ctx: ActionVisibilityContext, onExecuteAction: (action: ActionDefinition) => void, ): NavbarSectionItem[] { return items.reduce((result, item) => { if (isDivider(item)) { result.push({ type: "divider" }); return result; } const icon = getActionIcon(item, ctx); if (isSpecialIcon(icon)) { return result; } result.push({ type: "action", id: item.id, icon, isActive: isActionActive(item, ctx), tooltip: getActionTooltip(item, ctx), shortcut: item.shortcut, disabled: !isActionEnabled(item, ctx), onClick: () => onExecuteAction(item), }); return result; }, []); } /** * Desktop navbar for remote workspace and project pages. * * Mounted on workspace detail routes (/workspaces/:id) and project routes (/projects/:id) * where all required providers (ActionsProvider, WorkspaceProvider, etc.) are available. * * Mobile navbar is handled separately by RemoteNavbarContainer. */ export function RemoteDesktopNavbar() { const { executeAction } = useActions(); const { workspace: selectedWorkspace } = useWorkspaceContext(); const syncErrorContext = useSyncErrorContext(); const location = useLocation(); const isOnProjectPage = /^\/projects\/[^/]+/.test(location.pathname) || /^\/hosts\/[^/]+\/projects\/[^/]+/.test(location.pathname); const { data: orgsData } = useUserOrganizations(); const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId); const orgName = orgsData?.organizations.find((o) => o.id === selectedOrgId)?.name ?? ""; const actionCtx = useActionVisibilityContext(); const handleExecuteAction = useCallback( (action: ActionDefinition) => { if (action.requiresTarget && selectedWorkspace?.id) { executeAction(action, selectedWorkspace.id); } else { executeAction(action); } }, [executeAction, selectedWorkspace?.id], ); const isMigratePage = actionCtx.layoutMode === "migrate"; const leftItems = useMemo( () => isMigratePage ? [] : toNavbarSectionItems( filterNavbarItems(NavbarActionGroups.left, actionCtx), actionCtx, handleExecuteAction, ), [actionCtx, handleExecuteAction, isMigratePage], ); const rightItems = useMemo( () => isMigratePage ? [] : toNavbarSectionItems( filterNavbarItems(NavbarActionGroups.right, actionCtx), actionCtx, handleExecuteAction, ), [actionCtx, handleExecuteAction, isMigratePage], ); const handleOpenSettings = useCallback(() => { SettingsDialog.show({ sections: REMOTE_SETTINGS_SECTIONS }); }, []); const handleOpenCommandBar = useCallback(() => { CommandBarDialog.show(); }, []); const navbarTitle = isOnProjectPage ? orgName : selectedWorkspace?.branch; return ( ); } ================================================ FILE: packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx ================================================ import { useCallback, useEffect, useMemo, type ReactNode } from "react"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { MOBILE_TABS, Navbar, type MobileTabId, type NavbarSectionItem, } from "@vibe/ui/components/Navbar"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { CommandBarDialog } from "@/shared/dialogs/command-bar/CommandBarDialog"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; import { useMobileActiveTab } from "@/shared/stores/useUiPreferencesStore"; import { useMobileWorkspaceTitle } from "@remote/shared/stores/useMobileWorkspaceTitle"; import { useActions } from "@/shared/hooks/useActions"; import { useWorkspaceContext } from "@/shared/hooks/useWorkspaceContext"; import { useActionVisibilityContext } from "@/shared/hooks/useActionVisibilityContext"; import { NavbarActionGroups } from "@/shared/actions"; import { NavbarDivider, type ActionDefinition, type NavbarItem as ActionNavbarItem, type ActionVisibilityContext, isSpecialIcon, getActionIcon, getActionTooltip, isActionActive, isActionEnabled, isActionVisible, } from "@/shared/types/actions"; /** * Check if a NavbarItem is a divider */ function isDivider(item: ActionNavbarItem): item is typeof NavbarDivider { return "type" in item && item.type === "divider"; } /** * Filter navbar items by visibility, keeping dividers but removing them * if they would appear at the start, end, or consecutively. */ function filterNavbarItems( items: readonly ActionNavbarItem[], ctx: ActionVisibilityContext, ): ActionNavbarItem[] { const filtered = items.filter((item) => { if (isDivider(item)) return true; if (!isActionVisible(item, ctx)) return false; return !isSpecialIcon(getActionIcon(item, ctx)); }); const result: ActionNavbarItem[] = []; for (const item of filtered) { if (isDivider(item)) { if (result.length > 0 && !isDivider(result[result.length - 1])) { result.push(item); } } else { result.push(item); } } if (result.length > 0 && isDivider(result[result.length - 1])) { result.pop(); } return result; } function toNavbarSectionItems( items: readonly ActionNavbarItem[], ctx: ActionVisibilityContext, onExecuteAction: (action: ActionDefinition) => void, ): NavbarSectionItem[] { return items.reduce((result, item) => { if (isDivider(item)) { result.push({ type: "divider" }); return result; } const icon = getActionIcon(item, ctx); if (isSpecialIcon(icon)) { return result; } result.push({ type: "action", id: item.id, icon, isActive: isActionActive(item, ctx), tooltip: getActionTooltip(item, ctx), shortcut: item.shortcut, disabled: !isActionEnabled(item, ctx), onClick: () => onExecuteAction(item), }); return result; }, []); } interface RemoteNavbarContainerProps { organizationName: string | null; mobileMode?: boolean; onOpenDrawer?: () => void; mobileUserSlot?: ReactNode; } export function RemoteNavbarContainer({ organizationName, mobileMode, onOpenDrawer, mobileUserSlot, }: RemoteNavbarContainerProps) { const location = useLocation(); const { hostId } = useParams({ strict: false }); const mobileWorkspaceTitle = useMobileWorkspaceTitle((s) => s.title); const { executeAction } = useActions(); const { workspace: selectedWorkspace } = useWorkspaceContext(); const actionCtx = useActionVisibilityContext(); const [mobileActiveTab, setMobileActiveTab] = useMobileActiveTab(); const remoteMobileTabs = useMemo( () => MOBILE_TABS.filter((t) => t.id !== "preview" && t.id !== "workspaces"), [], ); const isOnWorkspaceView = /^\/hosts\/[^/]+\/workspaces\/[^/]+/.test( location.pathname, ); const isOnWorkspaceList = /^\/hosts\/[^/]+\/workspaces\/?$/.test( location.pathname, ); useEffect(() => { if (isOnWorkspaceView) { setMobileActiveTab("chat"); } }, [isOnWorkspaceView, setMobileActiveTab]); const navigate = useNavigate(); const isOnProjectPage = /^\/projects\/[^/]+/.test(location.pathname); const pathSegments = location.pathname.split("/").filter(Boolean); const projectSegmentIndex = pathSegments.indexOf("projects"); const projectId = projectSegmentIndex === -1 ? null : (pathSegments[projectSegmentIndex + 1] ?? null); const isOnProjectSubRoute = isOnProjectPage && (location.pathname.includes("/issues/") || location.pathname.includes("/workspaces/")); const handleExecuteAction = useCallback( (action: ActionDefinition) => { if (action.requiresTarget && selectedWorkspace?.id) { executeAction(action, selectedWorkspace.id); } else { executeAction(action); } }, [executeAction, selectedWorkspace?.id], ); const rightItems = useMemo( () => toNavbarSectionItems( filterNavbarItems(NavbarActionGroups.right, actionCtx), actionCtx, handleExecuteAction, ), [actionCtx, handleExecuteAction], ); const workspaceTitle = useMemo(() => { if (isOnProjectPage) { return organizationName ?? "Project"; } if (isOnWorkspaceView) { return mobileWorkspaceTitle ?? undefined; } return undefined; }, [ location.pathname, organizationName, isOnProjectPage, isOnWorkspaceView, mobileWorkspaceTitle, ]); const mobileShowBack = isOnWorkspaceView || isOnWorkspaceList; const handleNavigateBack = useCallback(() => { if (isOnProjectPage && projectId) { navigate({ to: "/projects/$projectId", params: { projectId }, }); } else if (isOnWorkspaceView) { if (!hostId) { navigate({ to: "/" }); return; } navigate({ to: "/hosts/$hostId/workspaces", params: { hostId } }); } else { navigate({ to: "/" }); } }, [navigate, hostId, isOnProjectPage, projectId, isOnWorkspaceView]); const handleOpenSettings = useCallback(() => { SettingsDialog.show({ sections: REMOTE_SETTINGS_SECTIONS }); }, []); const handleOpenCommandBar = useCallback(() => { CommandBarDialog.show(); }, []); return ( setMobileActiveTab(tab)} mobileTabs={remoteMobileTabs} showMobileTabs={isOnWorkspaceView} /> ); } ================================================ FILE: packages/remote-web/src/app/navigation/AppNavigation.ts ================================================ import { router } from "@remote/app/router"; import type { FileRouteTypes } from "@remote/routeTree.gen"; import { type AppDestination, type AppNavigation, type NavigationTransition, } from "@/shared/lib/routes/appNavigation"; type RemoteRouteId = FileRouteTypes["id"]; function getPathParam( routeParams: Record, key: string, ): string | null { const value = routeParams[key]; return value ? value : null; } export function resolveRemoteDestinationFromPath( path: string, ): AppDestination | null { const { pathname } = new URL(path, "http://localhost"); const { foundRoute, routeParams } = router.getMatchedRoutes(pathname); if (!foundRoute) { return null; } switch (foundRoute.id as RemoteRouteId) { case "/": return { kind: "root" }; case "/hosts/$hostId/workspaces": { const hostId = getPathParam(routeParams, "hostId"); return hostId ? { kind: "workspaces", hostId } : null; } case "/hosts/$hostId/workspaces_/create": { const hostId = getPathParam(routeParams, "hostId"); return hostId ? { kind: "workspaces-create", hostId } : null; } case "/hosts/$hostId/workspaces_/$workspaceId": { const hostId = getPathParam(routeParams, "hostId"); const workspaceId = getPathParam(routeParams, "workspaceId"); return hostId && workspaceId ? { kind: "workspace", hostId, workspaceId } : null; } case "/hosts/$hostId/workspaces/$workspaceId/vscode": { const hostId = getPathParam(routeParams, "hostId"); const workspaceId = getPathParam(routeParams, "workspaceId"); return hostId && workspaceId ? { kind: "workspace-vscode", hostId, workspaceId } : null; } case "/projects/$projectId": { const projectId = getPathParam(routeParams, "projectId"); return projectId ? { kind: "project", projectId } : null; } case "/projects/$projectId_/issues/$issueId": { const projectId = getPathParam(routeParams, "projectId"); const issueId = getPathParam(routeParams, "issueId"); return projectId && issueId ? { kind: "project-issue", projectId, issueId } : null; } case "/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId": { const projectId = getPathParam(routeParams, "projectId"); const issueId = getPathParam(routeParams, "issueId"); const hostId = getPathParam(routeParams, "hostId"); const workspaceId = getPathParam(routeParams, "workspaceId"); return projectId && issueId && hostId && workspaceId ? { kind: "project-issue-workspace", projectId, issueId, hostId, workspaceId, } : null; } case "/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId": { const projectId = getPathParam(routeParams, "projectId"); const issueId = getPathParam(routeParams, "issueId"); const hostId = getPathParam(routeParams, "hostId"); const draftId = getPathParam(routeParams, "draftId"); return projectId && issueId && hostId && draftId ? { kind: "project-issue-workspace-create", projectId, issueId, hostId, draftId, } : null; } case "/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId": { const projectId = getPathParam(routeParams, "projectId"); const hostId = getPathParam(routeParams, "hostId"); const draftId = getPathParam(routeParams, "draftId"); return projectId && hostId && draftId ? { kind: "project-workspace-create", projectId, hostId, draftId, } : null; } default: return null; } } function destinationToRemoteTarget( destination: AppDestination, options: { currentHostId: string | null }, ) { const destinationHostId = "hostId" in destination ? (destination.hostId ?? null) : null; const effectiveHostId = destinationHostId ?? options.currentHostId; switch (destination.kind) { case "root": return { to: "/" } as const; case "onboarding": return { to: "/" } as const; case "onboarding-sign-in": return { to: "/" } as const; case "migrate": return { to: "/" } as const; case "workspaces": if (effectiveHostId) { return { to: "/hosts/$hostId/workspaces", params: { hostId: effectiveHostId }, } as const; } return { to: "/" } as const; case "workspaces-create": if (effectiveHostId) { return { to: "/hosts/$hostId/workspaces/create", params: { hostId: effectiveHostId }, } as const; } return { to: "/" } as const; case "workspace": if (effectiveHostId) { return { to: "/hosts/$hostId/workspaces/$workspaceId", params: { hostId: effectiveHostId, workspaceId: destination.workspaceId, }, } as const; } return { to: "/" } as const; case "workspace-vscode": if (effectiveHostId) { return { to: "/hosts/$hostId/workspaces/$workspaceId/vscode", params: { hostId: effectiveHostId, workspaceId: destination.workspaceId, }, } as const; } return { to: "/" } as const; case "project": return { to: "/projects/$projectId", params: { projectId: destination.projectId }, } as const; case "project-issue": return { to: "/projects/$projectId/issues/$issueId", params: { projectId: destination.projectId, issueId: destination.issueId, }, } as const; case "project-issue-workspace": return { to: "/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId", params: { projectId: destination.projectId, issueId: destination.issueId, hostId: destination.hostId, workspaceId: destination.workspaceId, }, } as const; case "project-issue-workspace-create": return { to: "/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId", params: { projectId: destination.projectId, issueId: destination.issueId, hostId: destination.hostId, draftId: destination.draftId, }, } as const; case "project-workspace-create": return { to: "/projects/$projectId/hosts/$hostId/workspaces/create/$draftId", params: { projectId: destination.projectId, hostId: destination.hostId, draftId: destination.draftId, }, } as const; } } export function createRemoteHostAppNavigation(hostId: string): AppNavigation { const navigateTo = ( destination: AppDestination, transition?: NavigationTransition, ) => { void router.navigate({ ...destinationToRemoteTarget(destination, { currentHostId: hostId, }), ...(transition?.replace !== undefined ? { replace: transition.replace } : {}), }); }; const navigation: AppNavigation = { resolveFromPath: (path) => resolveRemoteDestinationFromPath(path), goToRoot: (transition) => navigateTo({ kind: "root" }, transition), goToOnboarding: (transition) => navigateTo({ kind: "onboarding" }, transition), goToOnboardingSignIn: (transition) => navigateTo({ kind: "onboarding-sign-in" }, transition), goToMigrate: (transition) => navigateTo({ kind: "migrate" }, transition), goToWorkspaces: (transition) => navigateTo({ kind: "workspaces", hostId }, transition), goToWorkspacesCreate: (transition) => navigateTo({ kind: "workspaces-create", hostId }, transition), goToWorkspace: (workspaceId, transition) => navigateTo({ kind: "workspace", hostId, workspaceId }, transition), goToWorkspaceVsCode: (workspaceId, transition) => navigateTo({ kind: "workspace-vscode", hostId, workspaceId }, transition), goToProject: (projectId, transition) => navigateTo({ kind: "project", projectId }, transition), goToProjectIssue: (projectId, issueId, transition) => navigateTo({ kind: "project-issue", projectId, issueId }, transition), goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) => navigateTo( { kind: "project-issue-workspace", hostId, projectId, issueId, workspaceId, }, transition, ), goToProjectIssueWorkspaceCreate: ( projectId, issueId, draftId, transition, ) => navigateTo( { kind: "project-issue-workspace-create", hostId, projectId, issueId, draftId, }, transition, ), goToProjectWorkspaceCreate: (projectId, draftId, transition) => navigateTo( { kind: "project-workspace-create", hostId, projectId, draftId }, transition, ), }; return navigation; } function createRemoteFallbackAppNavigation(): AppNavigation { const navigateTo = ( destination: AppDestination, transition?: NavigationTransition, ) => { void router.navigate({ ...destinationToRemoteTarget(destination, { currentHostId: null, }), ...(transition?.replace !== undefined ? { replace: transition.replace } : {}), }); }; const navigation: AppNavigation = { resolveFromPath: (path) => resolveRemoteDestinationFromPath(path), goToRoot: (transition) => navigateTo({ kind: "root" }, transition), goToOnboarding: (transition) => navigateTo({ kind: "onboarding" }, transition), goToOnboardingSignIn: (transition) => navigateTo({ kind: "onboarding-sign-in" }, transition), goToMigrate: (transition) => navigateTo({ kind: "migrate" }, transition), goToWorkspaces: (transition) => navigateTo({ kind: "workspaces" }, transition), goToWorkspacesCreate: (transition) => navigateTo({ kind: "workspaces-create" }, transition), goToWorkspace: (workspaceId, transition) => navigateTo({ kind: "workspace", workspaceId }, transition), goToWorkspaceVsCode: (workspaceId, transition) => navigateTo({ kind: "workspace-vscode", workspaceId }, transition), goToProject: (projectId, transition) => navigateTo({ kind: "project", projectId }, transition), goToProjectIssue: (projectId, issueId, transition) => navigateTo({ kind: "project-issue", projectId, issueId }, transition), goToProjectIssueWorkspace: (projectId, issueId, workspaceId, transition) => navigateTo( { kind: "project-issue-workspace", projectId, issueId, workspaceId }, transition, ), goToProjectIssueWorkspaceCreate: ( projectId, issueId, draftId, transition, ) => navigateTo( { kind: "project-issue-workspace-create", projectId, issueId, draftId }, transition, ), goToProjectWorkspaceCreate: (projectId, draftId, transition) => navigateTo( { kind: "project-workspace-create", projectId, draftId }, transition, ), }; return navigation; } export const remoteFallbackAppNavigation = createRemoteFallbackAppNavigation(); ================================================ FILE: packages/remote-web/src/app/providers/RemoteActionsProvider.tsx ================================================ import { useCallback, useContext, useMemo, useState, type ReactNode, } from "react"; import { useParams } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import type { Workspace } from "shared/types"; import { ActionsContext, type ActionsContextValue, } from "@/shared/hooks/useActions"; import { UserContext } from "@/shared/hooks/useUserContext"; import { type ActionDefinition, type ActionExecutorContext, type ActionVisibilityContext, getActionLabel, resolveLabel, type ProjectMutations, } from "@/shared/types/actions"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { useAppNavigation } from "@/shared/hooks/useAppNavigation"; import { useAppRuntime } from "@/shared/hooks/useAppRuntime"; import { useOrganizationStore } from "@/shared/stores/useOrganizationStore"; import { buildKanbanIssueComposerKey, openKanbanIssueComposer, type ProjectIssueCreateOptions, } from "@/shared/stores/useKanbanIssueComposerStore"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; interface RemoteActionsProviderProps { children: ReactNode; } function noOpSelection(name: string) { console.warn(`[RemoteActionsProvider] ${name} is unavailable in remote web.`); } export function RemoteActionsProvider({ children, }: RemoteActionsProviderProps) { const runtime = useAppRuntime(); const appNavigation = useAppNavigation(); const queryClient = useQueryClient(); const { projectId, hostId } = useParams({ strict: false }); const userCtx = useContext(UserContext); const selectedOrgId = useOrganizationStore((s) => s.selectedOrgId); const [defaultCreateStatusId, setDefaultCreateStatusId] = useState< string | undefined >(); const [projectMutations, setProjectMutations] = useState(null); const registerProjectMutations = useCallback( (mutations: ProjectMutations | null) => { setProjectMutations(mutations); }, [], ); const navigateToCreateIssue = useCallback( (options?: ProjectIssueCreateOptions) => { if (!projectId) return; openKanbanIssueComposer( buildKanbanIssueComposerKey(hostId ?? null, projectId), options, ); }, [hostId, projectId], ); const openStatusSelection = useCallback(async () => { noOpSelection("Status selection"); }, []); const openPrioritySelection = useCallback(async () => { noOpSelection("Priority selection"); }, []); const openAssigneeSelection = useCallback(async () => { noOpSelection("Assignee selection"); }, []); const openSubIssueSelection = useCallback(async () => { noOpSelection("Sub-issue selection"); return undefined; }, []); const openWorkspaceSelection = useCallback(async () => { noOpSelection("Workspace selection"); }, []); const openRelationshipSelection = useCallback(async () => { noOpSelection("Relationship selection"); }, []); const executorContext = useMemo( () => ({ appNavigation, queryClient, selectWorkspace: () => { noOpSelection("Workspace actions"); }, activeWorkspaces: [], currentWorkspaceId: null, containerRef: null, runningDevServers: [], startDevServer: () => { noOpSelection("Dev server actions"); }, stopDevServer: () => { noOpSelection("Dev server actions"); }, currentLogs: null, logsPanelContent: null, openStatusSelection, openPrioritySelection, openAssigneeSelection, openSubIssueSelection, openWorkspaceSelection, openRelationshipSelection, navigateToCreateIssue, defaultCreateStatusId, kanbanOrgId: selectedOrgId ?? undefined, kanbanProjectId: projectId, projectMutations: projectMutations ?? undefined, remoteWorkspaces: userCtx?.workspaces ?? [], runtime, }), [ runtime, queryClient, openStatusSelection, openPrioritySelection, openAssigneeSelection, openSubIssueSelection, openWorkspaceSelection, openRelationshipSelection, navigateToCreateIssue, defaultCreateStatusId, selectedOrgId, projectId, projectMutations, userCtx?.workspaces, ], ); const executeAction = useCallback( async (action: ActionDefinition): Promise => { if (action.id === "settings") { await SettingsDialog.show({ initialSection: "organizations", sections: REMOTE_SETTINGS_SECTIONS, }); return; } if (action.id === "project-settings") { await SettingsDialog.show({ initialSection: "remote-projects", initialState: { organizationId: selectedOrgId ?? undefined, projectId: projectId ?? undefined, }, sections: REMOTE_SETTINGS_SECTIONS, }); return; } console.warn( `[RemoteActionsProvider] Action "${action.id}" is unavailable in remote web.`, ); }, [projectId, selectedOrgId], ); const getLabel = useCallback( ( action: ActionDefinition, workspace?: Workspace, ctx?: ActionVisibilityContext, ) => { if (ctx) { return getActionLabel(action, ctx, workspace); } return resolveLabel(action, workspace); }, [], ); const value = useMemo( () => ({ executeAction, getLabel, openStatusSelection, openPrioritySelection, openAssigneeSelection, openSubIssueSelection, openWorkspaceSelection, openRelationshipSelection, setDefaultCreateStatusId, registerProjectMutations, executorContext, }), [ executeAction, getLabel, openStatusSelection, openPrioritySelection, openAssigneeSelection, openSubIssueSelection, openWorkspaceSelection, openRelationshipSelection, registerProjectMutations, executorContext, ], ); return ( {children} ); } ================================================ FILE: packages/remote-web/src/app/providers/RemoteAuthProvider.tsx ================================================ import { useEffect, useMemo, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AUTH_CHANGED_EVENT, isLoggedIn } from "@remote/shared/lib/auth"; import { getIdentity } from "@remote/shared/lib/api"; import { AuthContext, type AuthContextValue, } from "@/shared/hooks/auth/useAuth"; const TOKENS_QUERY_KEY = ["remote-auth", "tokens"] as const; const IDENTITY_QUERY_KEY = ["remote-auth", "identity"] as const; interface RemoteAuthProviderProps { children: ReactNode; } export function RemoteAuthProvider({ children }: RemoteAuthProviderProps) { const queryClient = useQueryClient(); const tokensQuery = useQuery({ queryKey: TOKENS_QUERY_KEY, queryFn: () => isLoggedIn(), staleTime: 0, refetchOnWindowFocus: true, }); const hasTokens = tokensQuery.data === true; const identityQuery = useQuery({ queryKey: IDENTITY_QUERY_KEY, queryFn: () => getIdentity(), enabled: hasTokens, retry: false, staleTime: 30_000, refetchOnWindowFocus: true, }); const identityUserId = identityQuery.data?.user_id ?? null; useEffect(() => { const handleAuthChanged = () => { void queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY }); void queryClient.invalidateQueries({ queryKey: IDENTITY_QUERY_KEY }); }; window.addEventListener(AUTH_CHANGED_EVENT, handleAuthChanged); return () => { window.removeEventListener(AUTH_CHANGED_EVENT, handleAuthChanged); }; }, [queryClient]); const value = useMemo(() => { if (tokensQuery.status === "pending") { return { isSignedIn: false, isLoaded: false, userId: null }; } if (!hasTokens) { return { isSignedIn: false, isLoaded: true, userId: null }; } if (identityQuery.status === "pending") { return { isSignedIn: false, isLoaded: false, userId: null }; } if (identityUserId) { return { isSignedIn: true, isLoaded: true, userId: identityUserId, }; } return { isSignedIn: false, isLoaded: true, userId: null }; }, [tokensQuery.status, hasTokens, identityQuery.status, identityUserId]); return {children}; } ================================================ FILE: packages/remote-web/src/app/providers/RemoteUserSystemProvider.tsx ================================================ import { ReactNode, useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams } from "@tanstack/react-router"; import type { BaseAgentCapability, Config, Environment, ExecutorProfile, UserSystemInfo, } from "shared/types"; import { configApi } from "@/shared/lib/api"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { UserSystemContext, type UserSystemContextType, } from "@/shared/hooks/useUserSystem"; interface RemoteUserSystemProviderProps { children: ReactNode; } export function RemoteUserSystemProvider({ children, }: RemoteUserSystemProviderProps) { const queryClient = useQueryClient(); const { isSignedIn, isLoaded } = useAuth(); const { hostId } = useParams({ strict: false }); const userSystemQueryKey = useMemo( () => ["remote-workspace-user-system", hostId] as const, [hostId], ); const { data: userSystemInfo, isLoading } = useQuery({ queryKey: userSystemQueryKey, queryFn: configApi.getConfig, enabled: isLoaded && isSignedIn && !!hostId, staleTime: 5 * 60 * 1000, }); const config = userSystemInfo?.config || null; const appVersion = userSystemInfo?.version || null; const environment = userSystemInfo?.environment || null; const analyticsUserId = userSystemInfo?.analytics_user_id || null; const loginStatus = userSystemInfo?.login_status || null; const profiles = (userSystemInfo?.executors as Record | null) || null; const capabilities = (userSystemInfo?.capabilities as Record< string, BaseAgentCapability[] > | null) || null; const loading = !isLoaded || (isSignedIn && isLoading); const updateConfig = useCallback( (updates: Partial) => { queryClient.setQueryData(userSystemQueryKey, (old) => { if (!old) return old; return { ...old, config: { ...old.config, ...updates }, }; }); }, [queryClient, userSystemQueryKey], ); const saveConfig = useCallback(async (): Promise => { if (!config) return false; try { await configApi.saveConfig(config); return true; } catch (err) { console.error("Error saving config:", err); return false; } }, [config]); const updateAndSaveConfig = useCallback( async (updates: Partial): Promise => { if (!config) return false; const newConfig = { ...config, ...updates }; updateConfig(updates); try { const saved = await configApi.saveConfig(newConfig); queryClient.setQueryData(userSystemQueryKey, (old) => { if (!old) return old; return { ...old, config: saved, }; }); return true; } catch (err) { console.error("Error saving config:", err); queryClient.invalidateQueries({ queryKey: userSystemQueryKey, }); return false; } }, [config, queryClient, updateConfig, userSystemQueryKey], ); const reloadSystem = useCallback(async () => { await queryClient.invalidateQueries({ queryKey: userSystemQueryKey, }); }, [queryClient, userSystemQueryKey]); const setEnvironment = useCallback( (env: Environment | null) => { queryClient.setQueryData(userSystemQueryKey, (old) => { if (!old || !env) return old; return { ...old, environment: env }; }); }, [queryClient, userSystemQueryKey], ); const setProfiles = useCallback( (newProfiles: Record | null) => { queryClient.setQueryData(userSystemQueryKey, (old) => { if (!old || !newProfiles) return old; return { ...old, executors: newProfiles as unknown as UserSystemInfo["executors"], }; }); }, [queryClient, userSystemQueryKey], ); const setCapabilities = useCallback( (newCapabilities: Record | null) => { queryClient.setQueryData(userSystemQueryKey, (old) => { if (!old || !newCapabilities) return old; return { ...old, capabilities: newCapabilities }; }); }, [queryClient, userSystemQueryKey], ); const value = useMemo( () => ({ system: { appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, }, appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, updateConfig, saveConfig, updateAndSaveConfig, setEnvironment, setProfiles, setCapabilities, reloadSystem, loading, }), [ appVersion, config, environment, profiles, capabilities, analyticsUserId, loginStatus, updateConfig, saveConfig, updateAndSaveConfig, setEnvironment, setProfiles, setCapabilities, reloadSystem, loading, ], ); return ( {children} ); } ================================================ FILE: packages/remote-web/src/app/router/index.ts ================================================ import { createRouter } from "@tanstack/react-router"; import { routeTree } from "@remote/routeTree.gen"; export const router = createRouter({ routeTree }); declare module "@tanstack/react-router" { interface Register { router: typeof router; } } ================================================ FILE: packages/remote-web/src/app/styles/index.css ================================================ @import "../../../../web-core/src/app/styles/new/index.css"; ================================================ FILE: packages/remote-web/src/pages/HomePage.tsx ================================================ import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import type { Project } from "shared/remote-types"; import type { OrganizationWithRole } from "shared/types"; import { listOrganizationProjects } from "@remote/shared/lib/api"; import { clearTokens } from "@remote/shared/lib/auth"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { useOrganizationStore } from "@/shared/stores/useOrganizationStore"; import { useUserOrganizations } from "@/shared/hooks/useUserOrganizations"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { useIsMobile } from "@/shared/hooks/useIsMobile"; import { resolveRelayNavigationHostId, useRelayAppBarHosts, } from "@remote/shared/hooks/useRelayAppBarHosts"; type OrganizationWithProjects = { organization: OrganizationWithRole; projects: Project[]; }; function getHostInitials(name: string): string { const trimmed = name.trim(); if (!trimmed) return "??"; const words = trimmed.split(/\s+/); if (words.length >= 2) { return (words[0][0] + words[1][0]).toUpperCase(); } return trimmed.slice(0, 2).toUpperCase(); } export default function HomePage() { const navigate = useNavigate(); const search = useSearch({ from: "/" }); const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId); const { data: orgsResponse, isLoading: orgsLoading, error: orgsError, } = useUserOrganizations(); const organizations = orgsResponse?.organizations; const [items, setItems] = useState([]); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [error, setError] = useState(null); const { isSignedIn } = useAuth(); const { hosts } = useRelayAppBarHosts(isSignedIn); const isMobile = useIsMobile(); const preferredHostId = useMemo( () => resolveRelayNavigationHostId(hosts), [hosts], ); const openRelaySettings = useCallback((hostId?: string) => { void SettingsDialog.show({ initialSection: "relay", ...(hostId ? { initialState: { hostId } } : {}), sections: REMOTE_SETTINGS_SECTIONS, }); }, []); useEffect(() => { const legacyOrgId = search.legacyOrgSettingsOrgId; if (!legacyOrgId) { return; } setSelectedOrgId(legacyOrgId); navigate({ to: "/", search: {}, replace: true, }); void SettingsDialog.show({ initialSection: "organizations", initialState: { organizationId: legacyOrgId }, sections: REMOTE_SETTINGS_SECTIONS, }); }, [navigate, search.legacyOrgSettingsOrgId, setSelectedOrgId]); const handleSignInAgain = async () => { await clearTokens(); navigate({ to: "/account", replace: true, }); }; useEffect(() => { if (!organizations) { return; } let cancelled = false; const load = async () => { setIsLoadingProjects(true); setError(null); try { const organizationsWithProjects = await Promise.all( organizations.map(async (organization) => { const projects = await listOrganizationProjects(organization.id); return { organization, projects: projects.sort((a, b) => a.sort_order - b.sort_order), }; }), ); if (!cancelled) { setItems(organizationsWithProjects); } } catch (e) { if (!cancelled) { setError( e instanceof Error ? e.message : "Failed to load organizations", ); } } finally { if (!cancelled) { setIsLoadingProjects(false); } } }; void load(); return () => { cancelled = true; }; }, [organizations]); const loading = orgsLoading || isLoadingProjects; const displayError = error ?? (orgsError ? orgsError instanceof Error ? orgsError.message : "Failed to load organizations" : null); if (loading) { return (

Organizations

Loading organizations and projects...

); } if (displayError) { return (

Failed to load

{displayError}

); } const organizationCount = items.length; const totalProjectCount = items.reduce( (count, item) => count + item.projects.length, 0, ); return (
{isMobile && isSignedIn && (

Your Hosts

{hosts.length === 0 ? (

No hosts linked yet

) : (
{hosts.map((host) => { const isOnline = host.status === "online"; const isUnpaired = host.status === "unpaired"; const isClickable = isOnline || isUnpaired; return ( ); })}
)}
)}

Organizations

{organizationCount}{" "} {organizationCount === 1 ? "organization" : "organizations"} •{" "} {totalProjectCount}{" "} {totalProjectCount === 1 ? "project" : "projects"}

{organizationCount === 0 ? (

No organizations found

Create or join an organization to start working on projects.

) : (
{items.map(({ organization, projects }) => ( ))}
)}
); } function CenteredCard({ children }: { children: ReactNode }) { return (
{children}
); } function OrganizationSection({ organization, projects, hostId, onRequireHost, }: OrganizationWithProjects & { hostId: string | null; onRequireHost: () => void; }) { return (

{organization.name}

{projects.length} {projects.length === 1 ? "project" : "projects"}

{projects.length === 0 ? (
No projects yet
) : (
    {projects.map((project) => (
  • ))} {projects.length % 2 === 1 ? ( ) : null}
)}
); } function ProjectCard({ project, hostId, onRequireHost, }: { project: Project; hostId: string | null; onRequireHost: () => void; }) { const setSelectedOrgId = useOrganizationStore((s) => s.setSelectedOrgId); if (!hostId) { return ( ); } return ( { setSelectedOrgId(project.organization_id); }} className="group flex h-[61px] flex-col justify-center rounded-sm border border-border bg-primary px-base py-base hover:border-high/20 hover:bg-panel focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand" >

{project.name}

Open project

); } function ProjectCardSkeleton() { return (
); } ================================================ FILE: packages/remote-web/src/pages/InvitationCompletePage.tsx ================================================ import { useEffect, useState } from "react"; import { useParams, useSearch } from "@tanstack/react-router"; import { acceptInvitation, redeemOAuth } from "@remote/shared/lib/api"; import { storeTokens } from "@remote/shared/lib/auth"; import { clearInvitationToken, clearVerifier, retrieveInvitationToken, retrieveVerifier, } from "@remote/shared/lib/pkce"; export default function InvitationCompletePage() { const { token: urlToken } = useParams({ from: "/invitations/$token/complete", }); const search = useSearch({ from: "/invitations/$token/complete" }); const [error, setError] = useState(null); const [isAccepted, setIsAccepted] = useState(false); const handoffId = search.handoff_id; const appCode = search.app_code; const oauthError = search.error; useEffect(() => { const completeInvitation = async () => { if (oauthError) { setError(`OAuth error: ${oauthError}`); return; } if (!handoffId || !appCode) { return; } try { const verifier = retrieveVerifier(); if (!verifier) { setError("OAuth session lost. Please try again."); return; } const token = retrieveInvitationToken() || urlToken; if (!token) { setError("Invitation token lost. Please try again."); return; } const { access_token, refresh_token } = await redeemOAuth( handoffId, appCode, verifier, ); await storeTokens(access_token, refresh_token); await acceptInvitation(token, access_token); clearVerifier(); clearInvitationToken(); setIsAccepted(true); } catch (e) { setError( e instanceof Error ? e.message : "Failed to complete invitation", ); clearVerifier(); clearInvitationToken(); } }; void completeInvitation(); }, [handoffId, appCode, oauthError, urlToken]); if (error) { const retryPath = urlToken ? `/invitations/${urlToken}/accept` : "/account"; return (

{error}

); } if (isAccepted) { return (

Your invitation is confirmed. You can now close this page.

Get started
); } return (

Processing OAuth callback...

); } function StatusCard({ title, variant, children, }: { title: string; variant?: "error"; children: React.ReactNode; }) { return (

{title}

{children}
); } ================================================ FILE: packages/remote-web/src/pages/InvitationPage.tsx ================================================ import { useEffect, useState } from "react"; import { useParams } from "@tanstack/react-router"; import { getInvitation, initOAuth, type InvitationLookupResponse, type OAuthProvider, } from "@remote/shared/lib/api"; import { generateChallenge, generateVerifier, storeInvitationToken, storeVerifier, } from "@remote/shared/lib/pkce"; export default function InvitationPage() { const { token } = useParams({ from: "/invitations/$token/accept" }); const [invitation, setInvitation] = useState( null, ); const [error, setError] = useState(null); const [pendingProvider, setPendingProvider] = useState( null, ); useEffect(() => { let cancelled = false; const loadInvitation = async () => { setError(null); setInvitation(null); try { const response = await getInvitation(token); if (!cancelled) { setInvitation(response); } } catch (e) { if (!cancelled) { setError( e instanceof Error ? e.message : "Failed to load invitation", ); } } }; void loadInvitation(); return () => { cancelled = true; }; }, [token]); const handleOAuthLogin = async (provider: OAuthProvider) => { setPendingProvider(provider); setError(null); try { const verifier = generateVerifier(); const challenge = await generateChallenge(verifier); storeVerifier(verifier); storeInvitationToken(token); const appBase = import.meta.env.VITE_APP_BASE_URL || window.location.origin; const callbackUrl = new URL(`/invitations/${token}/complete`, appBase); const { authorize_url } = await initOAuth( provider, callbackUrl.toString(), challenge, ); window.location.assign(authorize_url); } catch (e) { setError(e instanceof Error ? e.message : "OAuth init failed"); setPendingProvider(null); } }; if (error && !invitation) { return (

{error}

); } if (!invitation) { return (

Please wait.

); } return (

You're invited

You've been invited to join{" "} {invitation.organization_name ?? invitation.organization_slug} {" "} on Vibe Kanban.

Role {invitation.role}
Expires {new Date(invitation.expires_at).toLocaleDateString()}
{error && (

{error}

)}

Choose a provider to continue:

void handleOAuthLogin("github")} disabled={pendingProvider !== null} loading={pendingProvider === "github"} /> void handleOAuthLogin("google")} disabled={pendingProvider !== null} loading={pendingProvider === "google"} />
); } function OAuthButton({ provider, label, onClick, disabled, loading, }: { provider: OAuthProvider; label: string; onClick: () => void; disabled?: boolean; loading?: boolean; }) { return ( ); } function StatusCard({ title, variant, children, }: { title: string; variant?: "error"; children: React.ReactNode; }) { return (

{title}

{children}
); } ================================================ FILE: packages/remote-web/src/pages/LoginCompletePage.tsx ================================================ import { useEffect, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { redeemOAuth } from "@remote/shared/lib/api"; import { storeTokens } from "@remote/shared/lib/auth"; import { retrieveVerifier, clearVerifier } from "@remote/shared/lib/pkce"; function getSafeNextPath(nextPath: string | undefined): string { if (!nextPath) { return "/"; } if (!nextPath.startsWith("/") || nextPath.startsWith("//")) { return "/"; } return nextPath; } export default function LoginCompletePage() { const navigate = useNavigate(); const search = useSearch({ from: "/account_/complete" }); const [error, setError] = useState(null); const handoffId = search.handoff_id; const appCode = search.app_code; const oauthError = search.error; const nextPath = getSafeNextPath(search.next); useEffect(() => { const complete = async () => { if (oauthError) { setError(`OAuth error: ${oauthError}`); return; } if (!handoffId || !appCode) { return; } try { const verifier = retrieveVerifier(); if (!verifier) { setError("OAuth session lost. Please try again."); return; } const { access_token, refresh_token } = await redeemOAuth( handoffId, appCode, verifier, ); await storeTokens(access_token, refresh_token); clearVerifier(); window.location.replace(nextPath); } catch (e) { setError(e instanceof Error ? e.message : "Failed to complete login"); clearVerifier(); } }; void complete(); }, [handoffId, appCode, oauthError, nextPath]); if (error) { return (

{error}

); } return (

Processing OAuth callback...

); } function StatusCard({ title, variant, children, }: { title: string; variant?: "error"; children: React.ReactNode; }) { return (

{title}

{children}
); } ================================================ FILE: packages/remote-web/src/pages/LoginPage.tsx ================================================ import { useState } from "react"; import { useSearch } from "@tanstack/react-router"; import { initOAuth, type OAuthProvider } from "@remote/shared/lib/api"; import { BrandLogo } from "@remote/shared/components/BrandLogo"; import { generateVerifier, generateChallenge, storeVerifier, } from "@remote/shared/lib/pkce"; export default function LoginPage() { const { next } = useSearch({ from: "/account" }); const [error, setError] = useState(null); const [pending, setPending] = useState(null); const handleLogin = async (provider: OAuthProvider) => { setPending(provider); setError(null); try { const verifier = generateVerifier(); const challenge = await generateChallenge(verifier); storeVerifier(verifier); const appBase = import.meta.env.VITE_APP_BASE_URL || window.location.origin; const callbackUrl = new URL("/account/complete", appBase); if (next) { callbackUrl.searchParams.set("next", next); } const returnTo = callbackUrl.toString(); const { authorize_url } = await initOAuth(provider, returnTo, challenge); window.location.assign(authorize_url); } catch (e) { setError(e instanceof Error ? e.message : "OAuth init failed"); setPending(null); } }; return (

Sign in to continue

{error && (

{error}

)}
void handleLogin("github")} disabled={pending !== null} loading={pending === "github"} /> void handleLogin("google")} disabled={pending !== null} loading={pending === "google"} />

Need help getting started?{" "} Read the docs

); } function OAuthButton({ provider, label, onClick, disabled, loading, }: { provider: OAuthProvider; label: string; onClick: () => void; disabled?: boolean; loading?: boolean; }) { return ( ); } ================================================ FILE: packages/remote-web/src/pages/NotFoundPage.tsx ================================================ export default function NotFoundPage() { return (

404

Page not found

); } ================================================ FILE: packages/remote-web/src/pages/RemoteProjectKanbanShell.tsx ================================================ import { ProjectKanban } from "@/pages/kanban/ProjectKanban"; export function RemoteProjectKanbanShell() { return ; } ================================================ FILE: packages/remote-web/src/pages/RemoteWorkspacesPageShell.tsx ================================================ import { useEffect, type ReactNode } from "react"; import { useParams } from "@tanstack/react-router"; import WorkspacesUnavailablePage from "@remote/pages/WorkspacesUnavailablePage"; import { useRelayWorkspaceHostHealth } from "@remote/shared/hooks/useRelayWorkspaceHostHealth"; import { useWorkspaceContext } from "@/shared/hooks/useWorkspaceContext"; import { useMobileWorkspaceTitle } from "@remote/shared/stores/useMobileWorkspaceTitle"; interface RemoteWorkspacesPageShellProps { children: ReactNode; } function WorkspaceTitleSync() { const { workspace } = useWorkspaceContext(); const setTitle = useMobileWorkspaceTitle((s) => s.setTitle); useEffect(() => { setTitle(workspace?.name ?? workspace?.branch ?? null); return () => setTitle(null); }, [workspace?.name, workspace?.branch, setTitle]); return null; } export function RemoteWorkspacesPageShell({ children, }: RemoteWorkspacesPageShellProps) { const { hostId } = useParams({ strict: false }); const hostHealth = useRelayWorkspaceHostHealth(hostId ?? null); if (!hostId) { return ; } if (hostHealth.isChecking) { return ( ); } if (hostHealth.isError) { return ( ); } return ( <> {children} ); } ================================================ FILE: packages/remote-web/src/pages/UpgradeCompletePage.tsx ================================================ import { useEffect, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { redeemOAuth } from "@remote/shared/lib/api"; import { storeTokens } from "@remote/shared/lib/auth"; import { clearVerifier, retrieveVerifier } from "@remote/shared/lib/pkce"; export default function UpgradeCompletePage() { const navigate = useNavigate(); const search = useSearch({ from: "/upgrade_/complete" }); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const handoffId = search.handoff_id; const appCode = search.app_code; const oauthError = search.error; useEffect(() => { const completeLogin = async () => { if (oauthError) { setError(`OAuth error: ${oauthError}`); return; } if (!handoffId || !appCode) { return; } try { const verifier = retrieveVerifier(); if (!verifier) { setError("OAuth session lost. Please try again."); return; } const { access_token, refresh_token } = await redeemOAuth( handoffId, appCode, verifier, ); await storeTokens(access_token, refresh_token); clearVerifier(); setSuccess(true); setTimeout(() => { window.location.replace("/upgrade"); }, 700); } catch (e) { setError(e instanceof Error ? e.message : "Failed to complete login"); clearVerifier(); } }; void completeLogin(); }, [handoffId, appCode, oauthError]); if (error) { return (

{error}

); } if (success) { return (

Redirecting to complete your subscription...

); } return (

Processing OAuth callback...

); } function StatusCard({ title, variant, children, }: { title: string; variant?: "error"; children: React.ReactNode; }) { return (

{title}

{children}
); } ================================================ FILE: packages/remote-web/src/pages/UpgradePage.tsx ================================================ import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import type { OrganizationWithRole } from "shared/types"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { BrandLogo } from "@remote/shared/components/BrandLogo"; import { createCheckoutSession, initOAuth, listOrganizations, type OAuthProvider, } from "@remote/shared/lib/api"; import { generateChallenge, generateVerifier, storeVerifier, } from "@remote/shared/lib/pkce"; const UPGRADE_ORG_KEY = "upgrade_org_id"; const UPGRADE_RETURN_KEY = "upgrade_return"; type Step = "plan-selection" | "sign-in" | "org-selection"; export default function UpgradePage() { const search = useSearch({ from: "/upgrade" }); const navigate = useNavigate(); const { isSignedIn, isLoaded } = useAuth(); const [step, setStep] = useState("plan-selection"); const [organizations, setOrganizations] = useState( [], ); const [selectedOrgId, setSelectedOrgId] = useState(null); const [loadingOrganizations, setLoadingOrganizations] = useState(false); const [oauthLoading, setOauthLoading] = useState(null); const [checkoutLoading, setCheckoutLoading] = useState(false); const [error, setError] = useState(null); const loadOrganizations = useCallback(async () => { setLoadingOrganizations(true); setError(null); try { const { organizations: orgs } = await listOrganizations(); const eligibleOrgs = orgs.filter( (org) => !org.is_personal && org.user_role === "ADMIN", ); setOrganizations(eligibleOrgs); const savedOrgId = localStorage.getItem(UPGRADE_ORG_KEY); const preferredOrgId = search.org_id ?? savedOrgId; const preferredOrg = preferredOrgId ? eligibleOrgs.find((org) => org.id === preferredOrgId) : null; if (preferredOrg) { setSelectedOrgId(preferredOrg.id); } else { setSelectedOrgId(eligibleOrgs[0]?.id ?? null); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to load organizations"); } finally { setLoadingOrganizations(false); } }, [search.org_id]); useEffect(() => { if (search.org_id) { localStorage.setItem(UPGRADE_ORG_KEY, search.org_id); setSelectedOrgId(search.org_id); } }, [search.org_id]); useEffect(() => { if (!isLoaded || !isSignedIn) { return; } const isReturningFromUpgradeLogin = sessionStorage.getItem(UPGRADE_RETURN_KEY) === "true"; if (!isReturningFromUpgradeLogin) { return; } sessionStorage.removeItem(UPGRADE_RETURN_KEY); setStep("org-selection"); void loadOrganizations(); }, [isLoaded, isSignedIn, loadOrganizations]); const handleSubscribe = async () => { if (!isLoaded) { return; } if (isSignedIn) { setStep("org-selection"); await loadOrganizations(); return; } setStep("sign-in"); }; const handleOAuthLogin = async (provider: OAuthProvider) => { setOauthLoading(provider); setError(null); try { const verifier = generateVerifier(); const challenge = await generateChallenge(verifier); storeVerifier(verifier); sessionStorage.setItem(UPGRADE_RETURN_KEY, "true"); const appBase = import.meta.env.VITE_APP_BASE_URL || window.location.origin; const returnTo = `${appBase}/upgrade/complete`; const { authorize_url } = await initOAuth(provider, returnTo, challenge); window.location.assign(authorize_url); } catch (e) { setError(e instanceof Error ? e.message : "OAuth init failed"); setOauthLoading(null); } }; const handleCheckout = async () => { if (!selectedOrgId) { return; } setCheckoutLoading(true); setError(null); try { localStorage.setItem(UPGRADE_ORG_KEY, selectedOrgId); const appBase = import.meta.env.VITE_APP_BASE_URL || window.location.origin; const { url } = await createCheckoutSession( selectedOrgId, `${appBase}/upgrade/success`, `${appBase}/upgrade?org_id=${selectedOrgId}`, ); window.location.assign(url); } catch (e) { setError(e instanceof Error ? e.message : "Failed to start checkout"); setCheckoutLoading(false); } }; return (

Choose Your Plan

Pick the plan that fits your team and continue to checkout.

{error && (

{error}

)} {step === "plan-selection" && (
{ void handleSubscribe(); }} /> { window.location.assign( "mailto:louis@bloop.ai?subject=Enterprise%20Plan%20Inquiry", ); }} />
)} {step === "sign-in" && (

Sign In

Sign in to continue with your subscription.

{ void handleOAuthLogin("github"); }} disabled={oauthLoading !== null} loading={oauthLoading === "github"} /> { void handleOAuthLogin("google"); }} disabled={oauthLoading !== null} loading={oauthLoading === "google"} />
)} {step === "org-selection" && (

Select Organization

Choose the organization you want to upgrade.

{loadingOrganizations ? (

Loading organizations...

) : organizations.length === 0 ? ( <>

No eligible organizations found. You need admin access to a non-personal organization to upgrade.

) : ( <>
{organizations.map((organization) => ( ))}
)}
)}
); } function PlanCard({ name, price, priceUnit, description, features, popular, cta, onCta, }: { name: string; price: string; priceUnit?: string; description: string; features: string[]; popular?: boolean; cta?: string; onCta?: () => void; }) { return (

{name}

{price} {priceUnit && ( {priceUnit} )}

{description}

    {features.map((feature, index) => (
  • {feature}
  • ))}
{cta && onCta ? ( ) : (
Current plan
)}
); } function OAuthButton({ label, onClick, disabled, loading, }: { label: string; onClick: () => void; disabled?: boolean; loading?: boolean; }) { return ( ); } ================================================ FILE: packages/remote-web/src/pages/UpgradeSuccessPage.tsx ================================================ import { Link } from "@tanstack/react-router"; export default function UpgradeSuccessPage() { return (

Upgrade Complete

Your subscription is now active.

Continue in the web app, or return to your desktop app to keep working.

Go to Organizations
); } ================================================ FILE: packages/remote-web/src/pages/WorkspacesUnavailablePage.tsx ================================================ import { useMemo } from "react"; import { useParams } from "@tanstack/react-router"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { REMOTE_SETTINGS_SECTIONS } from "@remote/shared/constants/settings"; interface BlockedHostState { id: string; name: string | null; errorMessage?: string | null; } interface WorkspacesUnavailablePageProps { blockedHost?: BlockedHostState; isCheckingBlockedHost?: boolean; } export default function WorkspacesUnavailablePage({ blockedHost, isCheckingBlockedHost = false, }: WorkspacesUnavailablePageProps) { const { hostId } = useParams({ strict: false }); const selectedHostId = useMemo( () => blockedHost?.id ?? hostId ?? null, [blockedHost?.id, hostId], ); const selectedHostName = useMemo( () => blockedHost?.name ?? selectedHostId, [blockedHost?.name, selectedHostId], ); const isBlockedHostState = Boolean(blockedHost); const openRelaySettings = () => { void SettingsDialog.show({ initialSection: "relay", sections: REMOTE_SETTINGS_SECTIONS, }); }; return (

Workspaces

{isCheckingBlockedHost ? (

Connecting to{" "} {selectedHostName ?? "selected host"} ...

) : isBlockedHostState ? (

Could not connect to {selectedHostName ?? "the selected host"}.

This host is offline or no longer reachable from this browser.

  1. On that machine, open Vibe Kanban and confirm the host is online.
  2. If it still fails, open Relay Settings and pair this host again.
{blockedHost?.errorMessage && (

Last connection error: {blockedHost.errorMessage}

)}
) : (

Select an online host in the app bar to load local workspaces through relay.

)} {isBlockedHostState && (

After the host is online again, select it from the app bar and retry.

)}
); } ================================================ FILE: packages/remote-web/src/routeTree.gen.ts ================================================ /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' import { Route as UpgradeRouteImport } from './routes/upgrade' import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as LoginRouteImport } from './routes/login' import { Route as AccountRouteImport } from './routes/account' import { Route as IndexRouteImport } from './routes/index' import { Route as UpgradeSuccessRouteImport } from './routes/upgrade_.success' import { Route as UpgradeCompleteRouteImport } from './routes/upgrade_.complete' import { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId' import { Route as LoginCompleteRouteImport } from './routes/login_.complete' import { Route as AccountCompleteRouteImport } from './routes/account_.complete' import { Route as InvitationsTokenCompleteRouteImport } from './routes/invitations.$token.complete' import { Route as InvitationsTokenAcceptRouteImport } from './routes/invitations.$token.accept' import { Route as HostsHostIdWorkspacesRouteImport } from './routes/hosts.$hostId.workspaces' import { Route as AccountOrganizationsOrgIdRouteImport } from './routes/account_.organizations.$orgId' import { Route as ProjectsProjectIdIssuesIssueIdRouteImport } from './routes/projects.$projectId_.issues.$issueId' import { Route as HostsHostIdWorkspacesCreateRouteImport } from './routes/hosts.$hostId.workspaces_.create' import { Route as HostsHostIdWorkspacesWorkspaceIdRouteImport } from './routes/hosts.$hostId.workspaces_.$workspaceId' import { Route as HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport } from './routes/hosts.$hostId.workspaces.$workspaceId.vscode' import { Route as ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport } from './routes/projects.$projectId_.hosts.$hostId.workspaces.create.$draftId' import { Route as ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport } from './routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.$workspaceId' import { Route as ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport } from './routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.create.$draftId' const UpgradeRoute = UpgradeRouteImport.update({ id: '/upgrade', path: '/upgrade', getParentRoute: () => rootRouteImport, } as any) const NotificationsRoute = NotificationsRouteImport.update({ id: '/notifications', path: '/notifications', getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', getParentRoute: () => rootRouteImport, } as any) const AccountRoute = AccountRouteImport.update({ id: '/account', path: '/account', getParentRoute: () => rootRouteImport, } as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) const UpgradeSuccessRoute = UpgradeSuccessRouteImport.update({ id: '/upgrade_/success', path: '/upgrade/success', getParentRoute: () => rootRouteImport, } as any) const UpgradeCompleteRoute = UpgradeCompleteRouteImport.update({ id: '/upgrade_/complete', path: '/upgrade/complete', getParentRoute: () => rootRouteImport, } as any) const ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({ id: '/projects/$projectId', path: '/projects/$projectId', getParentRoute: () => rootRouteImport, } as any) const LoginCompleteRoute = LoginCompleteRouteImport.update({ id: '/login_/complete', path: '/login/complete', getParentRoute: () => rootRouteImport, } as any) const AccountCompleteRoute = AccountCompleteRouteImport.update({ id: '/account_/complete', path: '/account/complete', getParentRoute: () => rootRouteImport, } as any) const InvitationsTokenCompleteRoute = InvitationsTokenCompleteRouteImport.update({ id: '/invitations/$token/complete', path: '/invitations/$token/complete', getParentRoute: () => rootRouteImport, } as any) const InvitationsTokenAcceptRoute = InvitationsTokenAcceptRouteImport.update({ id: '/invitations/$token/accept', path: '/invitations/$token/accept', getParentRoute: () => rootRouteImport, } as any) const HostsHostIdWorkspacesRoute = HostsHostIdWorkspacesRouteImport.update({ id: '/hosts/$hostId/workspaces', path: '/hosts/$hostId/workspaces', getParentRoute: () => rootRouteImport, } as any) const AccountOrganizationsOrgIdRoute = AccountOrganizationsOrgIdRouteImport.update({ id: '/account_/organizations/$orgId', path: '/account/organizations/$orgId', getParentRoute: () => rootRouteImport, } as any) const ProjectsProjectIdIssuesIssueIdRoute = ProjectsProjectIdIssuesIssueIdRouteImport.update({ id: '/projects/$projectId_/issues/$issueId', path: '/projects/$projectId/issues/$issueId', getParentRoute: () => rootRouteImport, } as any) const HostsHostIdWorkspacesCreateRoute = HostsHostIdWorkspacesCreateRouteImport.update({ id: '/hosts/$hostId/workspaces_/create', path: '/hosts/$hostId/workspaces/create', getParentRoute: () => rootRouteImport, } as any) const HostsHostIdWorkspacesWorkspaceIdRoute = HostsHostIdWorkspacesWorkspaceIdRouteImport.update({ id: '/hosts/$hostId/workspaces_/$workspaceId', path: '/hosts/$hostId/workspaces/$workspaceId', getParentRoute: () => rootRouteImport, } as any) const HostsHostIdWorkspacesWorkspaceIdVscodeRoute = HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport.update({ id: '/$workspaceId/vscode', path: '/$workspaceId/vscode', getParentRoute: () => HostsHostIdWorkspacesRoute, } as any) const ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute = ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport.update({ id: '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId', path: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId', getParentRoute: () => rootRouteImport, } as any) const ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute = ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport.update( { id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId', path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId', getParentRoute: () => rootRouteImport, } as any, ) const ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute = ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport.update( { id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId', path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId', getParentRoute: () => rootRouteImport, } as any, ) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/account': typeof AccountRoute '/login': typeof LoginRoute '/notifications': typeof NotificationsRoute '/upgrade': typeof UpgradeRoute '/account/complete': typeof AccountCompleteRoute '/login/complete': typeof LoginCompleteRoute '/projects/$projectId': typeof ProjectsProjectIdRoute '/upgrade/complete': typeof UpgradeCompleteRoute '/upgrade/success': typeof UpgradeSuccessRoute '/account/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute '/hosts/$hostId/workspaces/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute '/hosts/$hostId/workspaces/create': typeof HostsHostIdWorkspacesCreateRoute '/projects/$projectId/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/account': typeof AccountRoute '/login': typeof LoginRoute '/notifications': typeof NotificationsRoute '/upgrade': typeof UpgradeRoute '/account/complete': typeof AccountCompleteRoute '/login/complete': typeof LoginCompleteRoute '/projects/$projectId': typeof ProjectsProjectIdRoute '/upgrade/complete': typeof UpgradeCompleteRoute '/upgrade/success': typeof UpgradeSuccessRoute '/account/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute '/hosts/$hostId/workspaces/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute '/hosts/$hostId/workspaces/create': typeof HostsHostIdWorkspacesCreateRoute '/projects/$projectId/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/account': typeof AccountRoute '/login': typeof LoginRoute '/notifications': typeof NotificationsRoute '/upgrade': typeof UpgradeRoute '/account_/complete': typeof AccountCompleteRoute '/login_/complete': typeof LoginCompleteRoute '/projects/$projectId': typeof ProjectsProjectIdRoute '/upgrade_/complete': typeof UpgradeCompleteRoute '/upgrade_/success': typeof UpgradeSuccessRoute '/account_/organizations/$orgId': typeof AccountOrganizationsOrgIdRoute '/hosts/$hostId/workspaces': typeof HostsHostIdWorkspacesRouteWithChildren '/invitations/$token/accept': typeof InvitationsTokenAcceptRoute '/invitations/$token/complete': typeof InvitationsTokenCompleteRoute '/hosts/$hostId/workspaces_/$workspaceId': typeof HostsHostIdWorkspacesWorkspaceIdRoute '/hosts/$hostId/workspaces_/create': typeof HostsHostIdWorkspacesCreateRoute '/projects/$projectId_/issues/$issueId': typeof ProjectsProjectIdIssuesIssueIdRoute '/hosts/$hostId/workspaces/$workspaceId/vscode': typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId': typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/account' | '/login' | '/notifications' | '/upgrade' | '/account/complete' | '/login/complete' | '/projects/$projectId' | '/upgrade/complete' | '/upgrade/success' | '/account/organizations/$orgId' | '/hosts/$hostId/workspaces' | '/invitations/$token/accept' | '/invitations/$token/complete' | '/hosts/$hostId/workspaces/$workspaceId' | '/hosts/$hostId/workspaces/create' | '/projects/$projectId/issues/$issueId' | '/hosts/$hostId/workspaces/$workspaceId/vscode' | '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId' | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId' | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId' fileRoutesByTo: FileRoutesByTo to: | '/' | '/account' | '/login' | '/notifications' | '/upgrade' | '/account/complete' | '/login/complete' | '/projects/$projectId' | '/upgrade/complete' | '/upgrade/success' | '/account/organizations/$orgId' | '/hosts/$hostId/workspaces' | '/invitations/$token/accept' | '/invitations/$token/complete' | '/hosts/$hostId/workspaces/$workspaceId' | '/hosts/$hostId/workspaces/create' | '/projects/$projectId/issues/$issueId' | '/hosts/$hostId/workspaces/$workspaceId/vscode' | '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId' | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId' | '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId' id: | '__root__' | '/' | '/account' | '/login' | '/notifications' | '/upgrade' | '/account_/complete' | '/login_/complete' | '/projects/$projectId' | '/upgrade_/complete' | '/upgrade_/success' | '/account_/organizations/$orgId' | '/hosts/$hostId/workspaces' | '/invitations/$token/accept' | '/invitations/$token/complete' | '/hosts/$hostId/workspaces_/$workspaceId' | '/hosts/$hostId/workspaces_/create' | '/projects/$projectId_/issues/$issueId' | '/hosts/$hostId/workspaces/$workspaceId/vscode' | '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId' | '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId' | '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AccountRoute: typeof AccountRoute LoginRoute: typeof LoginRoute NotificationsRoute: typeof NotificationsRoute UpgradeRoute: typeof UpgradeRoute AccountCompleteRoute: typeof AccountCompleteRoute LoginCompleteRoute: typeof LoginCompleteRoute ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute UpgradeCompleteRoute: typeof UpgradeCompleteRoute UpgradeSuccessRoute: typeof UpgradeSuccessRoute AccountOrganizationsOrgIdRoute: typeof AccountOrganizationsOrgIdRoute HostsHostIdWorkspacesRoute: typeof HostsHostIdWorkspacesRouteWithChildren InvitationsTokenAcceptRoute: typeof InvitationsTokenAcceptRoute InvitationsTokenCompleteRoute: typeof InvitationsTokenCompleteRoute HostsHostIdWorkspacesWorkspaceIdRoute: typeof HostsHostIdWorkspacesWorkspaceIdRoute HostsHostIdWorkspacesCreateRoute: typeof HostsHostIdWorkspacesCreateRoute ProjectsProjectIdIssuesIssueIdRoute: typeof ProjectsProjectIdIssuesIssueIdRoute ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute: typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/upgrade': { id: '/upgrade' path: '/upgrade' fullPath: '/upgrade' preLoaderRoute: typeof UpgradeRouteImport parentRoute: typeof rootRouteImport } '/notifications': { id: '/notifications' path: '/notifications' fullPath: '/notifications' preLoaderRoute: typeof NotificationsRouteImport parentRoute: typeof rootRouteImport } '/login': { id: '/login' path: '/login' fullPath: '/login' preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } '/account': { id: '/account' path: '/account' fullPath: '/account' preLoaderRoute: typeof AccountRouteImport parentRoute: typeof rootRouteImport } '/': { id: '/' path: '/' fullPath: '/' preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/upgrade_/success': { id: '/upgrade_/success' path: '/upgrade/success' fullPath: '/upgrade/success' preLoaderRoute: typeof UpgradeSuccessRouteImport parentRoute: typeof rootRouteImport } '/upgrade_/complete': { id: '/upgrade_/complete' path: '/upgrade/complete' fullPath: '/upgrade/complete' preLoaderRoute: typeof UpgradeCompleteRouteImport parentRoute: typeof rootRouteImport } '/projects/$projectId': { id: '/projects/$projectId' path: '/projects/$projectId' fullPath: '/projects/$projectId' preLoaderRoute: typeof ProjectsProjectIdRouteImport parentRoute: typeof rootRouteImport } '/login_/complete': { id: '/login_/complete' path: '/login/complete' fullPath: '/login/complete' preLoaderRoute: typeof LoginCompleteRouteImport parentRoute: typeof rootRouteImport } '/account_/complete': { id: '/account_/complete' path: '/account/complete' fullPath: '/account/complete' preLoaderRoute: typeof AccountCompleteRouteImport parentRoute: typeof rootRouteImport } '/invitations/$token/complete': { id: '/invitations/$token/complete' path: '/invitations/$token/complete' fullPath: '/invitations/$token/complete' preLoaderRoute: typeof InvitationsTokenCompleteRouteImport parentRoute: typeof rootRouteImport } '/invitations/$token/accept': { id: '/invitations/$token/accept' path: '/invitations/$token/accept' fullPath: '/invitations/$token/accept' preLoaderRoute: typeof InvitationsTokenAcceptRouteImport parentRoute: typeof rootRouteImport } '/hosts/$hostId/workspaces': { id: '/hosts/$hostId/workspaces' path: '/hosts/$hostId/workspaces' fullPath: '/hosts/$hostId/workspaces' preLoaderRoute: typeof HostsHostIdWorkspacesRouteImport parentRoute: typeof rootRouteImport } '/account_/organizations/$orgId': { id: '/account_/organizations/$orgId' path: '/account/organizations/$orgId' fullPath: '/account/organizations/$orgId' preLoaderRoute: typeof AccountOrganizationsOrgIdRouteImport parentRoute: typeof rootRouteImport } '/projects/$projectId_/issues/$issueId': { id: '/projects/$projectId_/issues/$issueId' path: '/projects/$projectId/issues/$issueId' fullPath: '/projects/$projectId/issues/$issueId' preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdRouteImport parentRoute: typeof rootRouteImport } '/hosts/$hostId/workspaces_/create': { id: '/hosts/$hostId/workspaces_/create' path: '/hosts/$hostId/workspaces/create' fullPath: '/hosts/$hostId/workspaces/create' preLoaderRoute: typeof HostsHostIdWorkspacesCreateRouteImport parentRoute: typeof rootRouteImport } '/hosts/$hostId/workspaces_/$workspaceId': { id: '/hosts/$hostId/workspaces_/$workspaceId' path: '/hosts/$hostId/workspaces/$workspaceId' fullPath: '/hosts/$hostId/workspaces/$workspaceId' preLoaderRoute: typeof HostsHostIdWorkspacesWorkspaceIdRouteImport parentRoute: typeof rootRouteImport } '/hosts/$hostId/workspaces/$workspaceId/vscode': { id: '/hosts/$hostId/workspaces/$workspaceId/vscode' path: '/$workspaceId/vscode' fullPath: '/hosts/$hostId/workspaces/$workspaceId/vscode' preLoaderRoute: typeof HostsHostIdWorkspacesWorkspaceIdVscodeRouteImport parentRoute: typeof HostsHostIdWorkspacesRoute } '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId': { id: '/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId' path: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId' fullPath: '/projects/$projectId/hosts/$hostId/workspaces/create/$draftId' preLoaderRoute: typeof ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRouteImport parentRoute: typeof rootRouteImport } '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId': { id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId' path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId' fullPath: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/$workspaceId' preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRouteImport parentRoute: typeof rootRouteImport } '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId': { id: '/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId' path: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId' fullPath: '/projects/$projectId/issues/$issueId/hosts/$hostId/workspaces/create/$draftId' preLoaderRoute: typeof ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRouteImport parentRoute: typeof rootRouteImport } } } interface HostsHostIdWorkspacesRouteChildren { HostsHostIdWorkspacesWorkspaceIdVscodeRoute: typeof HostsHostIdWorkspacesWorkspaceIdVscodeRoute } const HostsHostIdWorkspacesRouteChildren: HostsHostIdWorkspacesRouteChildren = { HostsHostIdWorkspacesWorkspaceIdVscodeRoute: HostsHostIdWorkspacesWorkspaceIdVscodeRoute, } const HostsHostIdWorkspacesRouteWithChildren = HostsHostIdWorkspacesRoute._addFileChildren( HostsHostIdWorkspacesRouteChildren, ) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccountRoute: AccountRoute, LoginRoute: LoginRoute, NotificationsRoute: NotificationsRoute, UpgradeRoute: UpgradeRoute, AccountCompleteRoute: AccountCompleteRoute, LoginCompleteRoute: LoginCompleteRoute, ProjectsProjectIdRoute: ProjectsProjectIdRoute, UpgradeCompleteRoute: UpgradeCompleteRoute, UpgradeSuccessRoute: UpgradeSuccessRoute, AccountOrganizationsOrgIdRoute: AccountOrganizationsOrgIdRoute, HostsHostIdWorkspacesRoute: HostsHostIdWorkspacesRouteWithChildren, InvitationsTokenAcceptRoute: InvitationsTokenAcceptRoute, InvitationsTokenCompleteRoute: InvitationsTokenCompleteRoute, HostsHostIdWorkspacesWorkspaceIdRoute: HostsHostIdWorkspacesWorkspaceIdRoute, HostsHostIdWorkspacesCreateRoute: HostsHostIdWorkspacesCreateRoute, ProjectsProjectIdIssuesIssueIdRoute: ProjectsProjectIdIssuesIssueIdRoute, ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute: ProjectsProjectIdHostsHostIdWorkspacesCreateDraftIdRoute, ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute: ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesWorkspaceIdRoute, ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute: ProjectsProjectIdIssuesIssueIdHostsHostIdWorkspacesCreateDraftIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() ================================================ FILE: packages/remote-web/src/routes/__root.tsx ================================================ import { type ReactNode, useEffect, useMemo } from "react"; import { createRootRoute, Outlet, useLocation, useParams, } from "@tanstack/react-router"; import { Provider as NiceModalProvider } from "@ebay/nice-modal-react"; import { useSystemTheme } from "@remote/shared/hooks/useSystemTheme"; import { RemoteActionsProvider } from "@remote/app/providers/RemoteActionsProvider"; import { RemoteUserSystemProvider } from "@remote/app/providers/RemoteUserSystemProvider"; import { RemoteAppShell } from "@remote/app/layout/RemoteAppShell"; import { UserProvider } from "@/shared/providers/remote/UserProvider"; import { WorkspaceProvider } from "@/shared/providers/WorkspaceProvider"; import { ExecutionProcessesProvider } from "@/shared/providers/ExecutionProcessesProvider"; import { TerminalProvider } from "@/shared/providers/TerminalProvider"; import { LogsPanelProvider } from "@/shared/providers/LogsPanelProvider"; import { ActionsProvider } from "@/shared/providers/ActionsProvider"; import { useAuth } from "@/shared/hooks/auth/useAuth"; import { useKanbanIssueComposerScratch } from "@/shared/hooks/useKanbanIssueComposerScratch"; import { useUiPreferencesScratch } from "@/shared/hooks/useUiPreferencesScratch"; import { useWorkspaceContext } from "@/shared/hooks/useWorkspaceContext"; import { AppNavigationProvider } from "@/shared/hooks/useAppNavigation"; import { SequenceTrackerProvider, SequenceIndicator, useWorkspaceShortcuts, useIssueShortcuts, useKeyShowHelp, Scope, } from "@/shared/keyboard"; import { KeyboardShortcutsDialog } from "@/shared/dialogs/shared/KeyboardShortcutsDialog"; import { createRemoteHostAppNavigation, remoteFallbackAppNavigation, resolveRemoteDestinationFromPath, } from "@remote/app/navigation/AppNavigation"; import { resolveRelayNavigationHostId, useRelayAppBarHosts, } from "@remote/shared/hooks/useRelayAppBarHosts"; import { setActiveRelayHostId } from "@remote/shared/lib/relay/activeHostContext"; import { isProjectDestination, isWorkspacesDestination, } from "@/shared/lib/routes/appNavigation"; import NotFoundPage from "../pages/NotFoundPage"; export const Route = createRootRoute({ component: RootLayout, notFoundComponent: NotFoundPage, }); function ExecutionProcessesProviderWrapper({ children, }: { children: ReactNode; }) { const { selectedSessionId } = useWorkspaceContext(); return ( {children} ); } /** * Global keyboard shortcut that doesn't require workspace/actions context. * Renders inside HotkeysProvider (from App.tsx) but outside WorkspaceProvider. */ function GlobalKeyboardShortcuts() { useKeyShowHelp( () => { KeyboardShortcutsDialog.show(); }, { scope: Scope.GLOBAL }, ); return null; } /** * Workspace & issue keyboard shortcuts that require ActionsProvider + WorkspaceProvider. * Must be rendered inside WorkspaceRouteProviders. */ function WorkspaceKeyboardShortcuts() { useWorkspaceShortcuts(); useIssueShortcuts(); return null; } function WorkspaceRouteProviders({ children }: { children: ReactNode }) { return ( {children} ); } function RootLayout() { useSystemTheme(); useUiPreferencesScratch(); useKanbanIssueComposerScratch(); const { isSignedIn } = useAuth(); const location = useLocation(); const { hostId } = useParams({ strict: false }); const routeHostId = hostId ?? null; const { hosts: relayHosts } = useRelayAppBarHosts(isSignedIn); const navigationHostId = useMemo( () => resolveRelayNavigationHostId(relayHosts, { routeHostId }), [relayHosts, routeHostId], ); useEffect(() => { setActiveRelayHostId(navigationHostId); }, [navigationHostId]); const appNavigation = useMemo( () => navigationHostId ? createRemoteHostAppNavigation(navigationHostId) : remoteFallbackAppNavigation, [navigationHostId], ); const isStandaloneRoute = location.pathname.startsWith("/account") || location.pathname.startsWith("/login") || location.pathname.startsWith("/upgrade") || location.pathname.startsWith("/invitations"); const destination = resolveRemoteDestinationFromPath(location.pathname); const isWorkspaceProviderRoute = isProjectDestination(destination) || isWorkspacesDestination(destination); const pageContent = isStandaloneRoute ? ( ) : ( ); const content = isWorkspaceProviderRoute ? ( {pageContent} ) : ( {pageContent} ); return ( {content} ); } ================================================ FILE: packages/remote-web/src/routes/account.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; import { redirectAuthenticatedToHome } from "@remote/shared/lib/route-auth"; import LoginPage from "../pages/LoginPage"; const searchSchema = z.object({ next: z.string().optional(), }); export const Route = createFileRoute("/account")({ validateSearch: zodValidator(searchSchema), beforeLoad: async () => { await redirectAuthenticatedToHome(); }, component: LoginPage, }); ================================================ FILE: packages/remote-web/src/routes/account_.complete.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { zodValidator } from "@tanstack/zod-adapter"; import LoginCompletePage from "../pages/LoginCompletePage"; const searchSchema = z.object({ handoff_id: z.string().optional(), app_code: z.string().optional(), error: z.string().optional(), next: z.string().optional(), }); export const Route = createFileRoute("/account_/complete")({ component: LoginCompletePage, validateSearch: zodValidator(searchSchema), }); ================================================ FILE: packages/remote-web/src/routes/account_.organizations.$orgId.tsx ================================================ import { createFileRoute, redirect } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; export const Route = createFileRoute("/account_/organizations/$orgId")({ beforeLoad: async ({ location, params }) => { await requireAuthenticated(location); throw redirect({ to: "/", search: { legacyOrgSettingsOrgId: params.orgId }, }); }, }); ================================================ FILE: packages/remote-web/src/routes/hosts.$hostId.workspaces.$workspaceId.vscode.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { VSCodeWorkspacePage } from "@/pages/workspaces/VSCodeWorkspacePage"; import { RemoteWorkspacesPageShell } from "@remote/pages/RemoteWorkspacesPageShell"; export const Route = createFileRoute( "/hosts/$hostId/workspaces/$workspaceId/vscode", )({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: WorkspaceVSCodeRouteComponent, }); function WorkspaceVSCodeRouteComponent() { return ( ); } ================================================ FILE: packages/remote-web/src/routes/hosts.$hostId.workspaces.tsx ================================================ import { useState } from "react"; import { createFileRoute, useNavigate, useParams, } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { WorkspacesLanding } from "@/pages/workspaces/WorkspacesLanding"; import { RemoteWorkspacesPageShell } from "@remote/pages/RemoteWorkspacesPageShell"; import { useIsMobile } from "@/shared/hooks/useIsMobile"; import { useWorkspaceContext } from "@/shared/hooks/useWorkspaceContext"; import { cn } from "@/shared/lib/utils"; import { CommandBarDialog } from "@/shared/dialogs/command-bar/CommandBarDialog"; import { PlusIcon, GitBranchIcon, HandIcon, TriangleIcon, PlayIcon, FileIcon, CircleIcon, GitPullRequestIcon, PushPinIcon, DotsThreeIcon, ArchiveIcon, ArrowLeftIcon, } from "@phosphor-icons/react"; import { RunningDots } from "@vibe/ui/components/RunningDots"; export const Route = createFileRoute("/hosts/$hostId/workspaces")({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: WorkspacesRouteComponent, }); function WorkspacesRouteComponent() { const isMobile = useIsMobile(); return ( {isMobile ? : } ); } function MobileWorkspacesList() { const navigate = useNavigate(); const { hostId } = useParams({ from: "/hosts/$hostId/workspaces" }); const { activeWorkspaces, archivedWorkspaces, selectWorkspace } = useWorkspaceContext(); const [showArchive, setShowArchive] = useState(false); const workspaces = showArchive ? archivedWorkspaces : activeWorkspaces; const handleSelectWorkspace = (id: string) => { selectWorkspace(id); navigate({ to: "/hosts/$hostId/workspaces/$workspaceId", params: { hostId, workspaceId: id }, }); }; const handleCreateWorkspace = () => { navigate({ to: "/hosts/$hostId/workspaces/create", params: { hostId } }); }; return (
{/* Header */}

{showArchive ? "Archived" : "Workspaces"}

{/* Workspace list */}
{workspaces.length === 0 ? (

{showArchive ? "No archived workspaces" : "No workspaces yet"}

{!showArchive && ( )}
) : (
{workspaces.map((workspace) => { const isFailed = workspace.latestProcessStatus === "failed" || workspace.latestProcessStatus === "killed"; const hasChanges = workspace.filesChanged !== undefined && workspace.filesChanged > 0; return (
{/* Workspace actions menu */}
); })}
)}
{/* Fixed footer toggle */}
); } const formatRelativeElapsed = (dateString: string): string => { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; return `${diffDays}d ago`; }; ================================================ FILE: packages/remote-web/src/routes/hosts.$hostId.workspaces_.$workspaceId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { Workspaces } from "@/pages/workspaces/Workspaces"; import { RemoteWorkspacesPageShell } from "@remote/pages/RemoteWorkspacesPageShell"; export const Route = createFileRoute("/hosts/$hostId/workspaces_/$workspaceId")( { beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: WorkspaceRouteComponent, }, ); function WorkspaceRouteComponent() { return ( ); } ================================================ FILE: packages/remote-web/src/routes/hosts.$hostId.workspaces_.create.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { Workspaces } from "@/pages/workspaces/Workspaces"; import { RemoteWorkspacesPageShell } from "@remote/pages/RemoteWorkspacesPageShell"; export const Route = createFileRoute("/hosts/$hostId/workspaces_/create")({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: WorkspacesCreateRouteComponent, }); function WorkspacesCreateRouteComponent() { return ( ); } ================================================ FILE: packages/remote-web/src/routes/index.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import HomePage from "../pages/HomePage"; const searchSchema = z.object({ legacyOrgSettingsOrgId: z.string().optional(), }); export const Route = createFileRoute("/")({ validateSearch: zodValidator(searchSchema), beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: HomePage, }); ================================================ FILE: packages/remote-web/src/routes/invitations.$token.accept.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import InvitationPage from "../pages/InvitationPage"; export const Route = createFileRoute("/invitations/$token/accept")({ component: InvitationPage, }); ================================================ FILE: packages/remote-web/src/routes/invitations.$token.complete.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { zodValidator } from "@tanstack/zod-adapter"; import InvitationCompletePage from "../pages/InvitationCompletePage"; const searchSchema = z.object({ handoff_id: z.string().optional(), app_code: z.string().optional(), error: z.string().optional(), }); export const Route = createFileRoute("/invitations/$token/complete")({ validateSearch: zodValidator(searchSchema), component: InvitationCompletePage, }); ================================================ FILE: packages/remote-web/src/routes/login.tsx ================================================ import { createFileRoute, redirect } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; const searchSchema = z.object({ next: z.string().optional(), }); export const Route = createFileRoute("/login")({ validateSearch: zodValidator(searchSchema), beforeLoad: ({ search }) => { throw redirect({ to: "/account", search: search.next ? { next: search.next } : undefined, }); }, }); ================================================ FILE: packages/remote-web/src/routes/login_.complete.tsx ================================================ import { createFileRoute, redirect } from "@tanstack/react-router"; import { z } from "zod"; import { zodValidator } from "@tanstack/zod-adapter"; const searchSchema = z.object({ handoff_id: z.string().optional(), app_code: z.string().optional(), error: z.string().optional(), next: z.string().optional(), }); export const Route = createFileRoute("/login_/complete")({ validateSearch: zodValidator(searchSchema), beforeLoad: ({ search }) => { throw redirect({ to: "/account/complete", search, }); }, }); ================================================ FILE: packages/remote-web/src/routes/notifications.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { NotificationsPage } from "@/pages/workspaces/NotificationsPage"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; export const Route = createFileRoute("/notifications")({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, component: NotificationsPage, }); ================================================ FILE: packages/remote-web/src/routes/projects.$projectId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { ProjectKanban } from "@/pages/kanban/ProjectKanban"; import { projectSearchValidator } from "@vibe/web-core/project-search"; export const Route = createFileRoute("/projects/$projectId")({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, validateSearch: projectSearchValidator, component: ProjectKanban, }); ================================================ FILE: packages/remote-web/src/routes/projects.$projectId_.hosts.$hostId.workspaces.create.$draftId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { projectSearchValidator } from "@vibe/web-core/project-search"; import { RemoteProjectKanbanShell } from "@remote/pages/RemoteProjectKanbanShell"; export const Route = createFileRoute( "/projects/$projectId_/hosts/$hostId/workspaces/create/$draftId", )({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, validateSearch: projectSearchValidator, component: RemoteProjectKanbanShell, }); ================================================ FILE: packages/remote-web/src/routes/projects.$projectId_.issues.$issueId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { ProjectKanban } from "@/pages/kanban/ProjectKanban"; import { projectSearchValidator } from "@vibe/web-core/project-search"; export const Route = createFileRoute("/projects/$projectId_/issues/$issueId")({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, validateSearch: projectSearchValidator, component: ProjectKanban, }); ================================================ FILE: packages/remote-web/src/routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.$workspaceId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { projectSearchValidator } from "@vibe/web-core/project-search"; import { RemoteProjectKanbanShell } from "@remote/pages/RemoteProjectKanbanShell"; export const Route = createFileRoute( "/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/$workspaceId", )({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, validateSearch: projectSearchValidator, component: RemoteProjectKanbanShell, }); ================================================ FILE: packages/remote-web/src/routes/projects.$projectId_.issues.$issueId_.hosts.$hostId.workspaces.create.$draftId.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { requireAuthenticated } from "@remote/shared/lib/route-auth"; import { projectSearchValidator } from "@vibe/web-core/project-search"; import { RemoteProjectKanbanShell } from "@remote/pages/RemoteProjectKanbanShell"; export const Route = createFileRoute( "/projects/$projectId_/issues/$issueId_/hosts/$hostId/workspaces/create/$draftId", )({ beforeLoad: async ({ location }) => { await requireAuthenticated(location); }, validateSearch: projectSearchValidator, component: RemoteProjectKanbanShell, }); ================================================ FILE: packages/remote-web/src/routes/upgrade.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; import UpgradePage from "@remote/pages/UpgradePage"; const searchSchema = z.object({ org_id: z.string().optional(), }); export const Route = createFileRoute("/upgrade")({ validateSearch: zodValidator(searchSchema), component: UpgradePage, }); ================================================ FILE: packages/remote-web/src/routes/upgrade_.complete.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { z } from "zod"; import UpgradeCompletePage from "@remote/pages/UpgradeCompletePage"; const searchSchema = z.object({ handoff_id: z.string().optional(), app_code: z.string().optional(), error: z.string().optional(), }); export const Route = createFileRoute("/upgrade_/complete")({ validateSearch: zodValidator(searchSchema), component: UpgradeCompletePage, }); ================================================ FILE: packages/remote-web/src/routes/upgrade_.success.tsx ================================================ import { createFileRoute } from "@tanstack/react-router"; import UpgradeSuccessPage from "@remote/pages/UpgradeSuccessPage"; export const Route = createFileRoute("/upgrade_/success")({ component: UpgradeSuccessPage, }); ================================================ FILE: packages/remote-web/src/shared/components/BrandLogo.tsx ================================================ interface BrandLogoProps { className?: string; alt?: string; } export function BrandLogo({ className = "h-8 w-auto", alt = "Vibe Kanban", }: BrandLogoProps) { return ( {alt} ); } ================================================ FILE: packages/remote-web/src/shared/constants/settings.ts ================================================ import type { SettingsSectionType } from "@/shared/dialogs/settings/settings/SettingsSection"; export const REMOTE_SETTINGS_SECTIONS: SettingsSectionType[] = [ "organizations", "remote-projects", "relay", ]; ================================================ FILE: packages/remote-web/src/shared/hooks/useRelayAppBarHosts.ts ================================================ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import type { AppBarHost } from "@vibe/ui/components/AppBar"; import type { RelayHost } from "shared/remote-types"; import { listPairedRelayHosts } from "@/shared/lib/relayPairingStorage"; import { listRelayHosts } from "@/shared/lib/remoteApi"; const RELAY_APP_BAR_HOSTS_QUERY_KEY = ["relay-app-bar-hosts", "hosts"] as const; const RELAY_APP_BAR_PAIRED_HOSTS_QUERY_KEY = [ "relay-app-bar-hosts", "paired-hosts", ] as const; interface UseRelayAppBarHostsResult { hosts: AppBarHost[]; isLoading: boolean; } export interface ResolveRelayNavigationHostOptions { routeHostId?: string | null; } export function resolveRelayNavigationHostId( hosts: AppBarHost[], options?: ResolveRelayNavigationHostOptions, ): string | null { const routeHostId = options?.routeHostId ?? null; if (routeHostId) { return routeHostId; } const onlineHost = hosts.find((host) => host.status === "online"); if (onlineHost) { return onlineHost.id; } return null; } function mapRelayHostStatus( host: RelayHost, pairedHostIds: Set, ): AppBarHost["status"] { if (!pairedHostIds.has(host.id)) { return "unpaired"; } return host.status === "online" ? "online" : "offline"; } export function useRelayAppBarHosts( enabled: boolean, ): UseRelayAppBarHostsResult { const hostsQuery = useQuery({ queryKey: RELAY_APP_BAR_HOSTS_QUERY_KEY, queryFn: listRelayHosts, enabled, staleTime: 30_000, refetchInterval: 30_000, }); const pairedHostsQuery = useQuery({ queryKey: RELAY_APP_BAR_PAIRED_HOSTS_QUERY_KEY, queryFn: async () => { try { return await listPairedRelayHosts(); } catch (error) { console.error("Failed to load paired relay hosts for app bar", error); return []; } }, enabled, staleTime: 5_000, refetchInterval: 5_000, }); const hosts = useMemo(() => { if (!enabled) { return []; } const relayHosts = hostsQuery.data ?? []; const pairedHostIds = new Set( (pairedHostsQuery.data ?? []).map((host) => host.host_id), ); return relayHosts.map((host) => ({ id: host.id, name: host.name, status: mapRelayHostStatus(host, pairedHostIds), })); }, [enabled, hostsQuery.data, pairedHostsQuery.data]); return { hosts, isLoading: enabled && (hostsQuery.isLoading || pairedHostsQuery.isLoading), }; } ================================================ FILE: packages/remote-web/src/shared/hooks/useRelayWorkspaceHostHealth.ts ================================================ import { useQuery } from "@tanstack/react-query"; import { makeLocalApiRequest } from "@/shared/lib/localApiTransport"; interface UseRelayWorkspaceHostHealthResult { isChecking: boolean; isError: boolean; errorMessage: string | null; } function getErrorMessage(error: unknown): string | null { if (error instanceof Error && error.message.length > 0) { return error.message; } return null; } export function useRelayWorkspaceHostHealth( hostId: string | null, ): UseRelayWorkspaceHostHealthResult { const hostHealthQuery = useQuery({ queryKey: ["remote-workspaces-host-health", hostId], enabled: !!hostId, retry: false, staleTime: 5_000, refetchInterval: 15_000, queryFn: async (): Promise => { const response = await makeLocalApiRequest("/api/info", { cache: "no-store", }); if (!response.ok) { throw new Error(`Host returned HTTP ${response.status}`); } return true; }, }); const isHostUnavailable = hostHealthQuery.isError || hostHealthQuery.isRefetchError; return { isChecking: hostHealthQuery.isPending, isError: isHostUnavailable, errorMessage: isHostUnavailable ? getErrorMessage(hostHealthQuery.error) : null, }; } ================================================ FILE: packages/remote-web/src/shared/hooks/useSystemTheme.ts ================================================ import { useEffect } from "react"; /** * Applies 'dark' or 'light' class to based on the browser's * prefers-color-scheme, and updates live when the OS setting changes. */ export function useSystemTheme() { useEffect(() => { const query = window.matchMedia("(prefers-color-scheme: dark)"); function apply(dark: boolean) { const root = document.documentElement; root.classList.toggle("dark", dark); root.classList.toggle("light", !dark); } apply(query.matches); function onChange(e: MediaQueryListEvent) { apply(e.matches); } query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }, []); } ================================================ FILE: packages/remote-web/src/shared/lib/api.ts ================================================ import { getToken, triggerRefresh } from "@remote/shared/lib/auth/tokenManager"; import { clearTokens } from "@remote/shared/lib/auth"; import type { Project } from "shared/remote-types"; import type { ListOrganizationsResponse } from "shared/types"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; export type OAuthProvider = "github" | "google"; type HandoffInitResponse = { handoff_id: string; authorize_url: string; }; type HandoffRedeemResponse = { access_token: string; refresh_token: string; }; export type InvitationLookupResponse = { id: string; organization_slug: string; organization_name?: string; role: string; expires_at: string; }; type AcceptInvitationResponse = { organization_id: string; organization_slug: string; role: string; }; type IdentityResponse = { user_id: string; username: string | null; email: string; }; export async function initOAuth( provider: OAuthProvider, returnTo: string, appChallenge: string, ): Promise { const res = await fetch(`${API_BASE}/v1/oauth/web/init`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, return_to: returnTo, app_challenge: appChallenge, }), }); if (!res.ok) { throw new Error(`OAuth init failed (${res.status})`); } return res.json(); } export async function redeemOAuth( handoffId: string, appCode: string, appVerifier: string, ): Promise { const res = await fetch(`${API_BASE}/v1/oauth/web/redeem`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ handoff_id: handoffId, app_code: appCode, app_verifier: appVerifier, }), }); if (!res.ok) { throw new Error(`OAuth redeem failed (${res.status})`); } return res.json(); } export async function getInvitation( token: string, ): Promise { const res = await fetch(`${API_BASE}/v1/invitations/${token}`); if (!res.ok) { throw new Error(`Invitation not found (${res.status})`); } return res.json(); } export async function acceptInvitation( token: string, accessToken: string, ): Promise { const res = await fetch(`${API_BASE}/v1/invitations/${token}/accept`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, }); if (!res.ok) { throw new Error(`Failed to accept invitation (${res.status})`); } return res.json(); } export async function refreshTokens( refreshToken: string, ): Promise<{ access_token: string; refresh_token: string }> { const res = await fetch(`${API_BASE}/v1/tokens/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) { const err = new Error(`Token refresh failed (${res.status})`); (err as Error & { status: number }).status = res.status; throw err; } return res.json(); } export async function authenticatedFetch( url: string, options: RequestInit = {}, ): Promise { const accessToken = await getToken(); const res = await fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}`, }, }); if (res.status === 401) { const newAccessToken = await triggerRefresh(); return fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${newAccessToken}`, }, }); } return res; } export async function logout(): Promise { try { await authenticatedFetch(`${API_BASE}/v1/oauth/logout`, { method: "POST", }); } finally { await clearTokens(); } } export async function listOrganizations(): Promise { const res = await authenticatedFetch(`${API_BASE}/v1/organizations`); if (!res.ok) { throw new Error(`Failed to list organizations (${res.status})`); } return res.json(); } export async function getIdentity(): Promise { const res = await authenticatedFetch(`${API_BASE}/v1/identity`); if (!res.ok) { throw new Error(`Failed to fetch identity (${res.status})`); } return res.json(); } export async function listOrganizationProjects( organizationId: string, ): Promise { const params = new URLSearchParams({ organization_id: organizationId, }); const res = await authenticatedFetch(`${API_BASE}/v1/projects?${params}`); if (!res.ok) { throw new Error(`Failed to list projects (${res.status})`); } const body = (await res.json()) as { projects: Project[] }; return body.projects; } export async function createCheckoutSession( organizationId: string, successUrl: string, cancelUrl: string, ): Promise<{ url: string }> { const res = await authenticatedFetch( `${API_BASE}/v1/organizations/${organizationId}/billing/checkout`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ success_url: successUrl, cancel_url: cancelUrl, }), }, ); if (!res.ok) { throw new Error(`Failed to create checkout session (${res.status})`); } return res.json(); } ================================================ FILE: packages/remote-web/src/shared/lib/auth/tokenManager.ts ================================================ import { getAccessToken, getRefreshToken, storeTokens, clearAccessToken, clearTokens, } from "@remote/shared/lib/auth"; import { shouldRefreshAccessToken } from "shared/jwt"; import { refreshTokens } from "@remote/shared/lib/api"; const TOKEN_REFRESH_TIMEOUT_MS = 80_000; const TOKEN_REFRESH_MAX_ATTEMPTS = 3; async function refreshWithRetry(refreshToken: string) { for (let attempt = 1; attempt <= TOKEN_REFRESH_MAX_ATTEMPTS; attempt++) { const backoffMs = Math.min(500 * 2 ** (attempt - 1), 2000); let timeoutId: ReturnType; try { return await Promise.race([ refreshTokens(refreshToken), new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("Token refresh timed out")), TOKEN_REFRESH_TIMEOUT_MS, ); }), ]); } catch (error) { const isTimeout = error instanceof Error && error.message === "Token refresh timed out"; if (isTimeout) throw error; const status = (error as { status?: number }).status; const isRetryable = !status || status >= 500 || error instanceof TypeError; if (isRetryable && attempt < TOKEN_REFRESH_MAX_ATTEMPTS) { await new Promise((r) => setTimeout(r, backoffMs)); continue; } throw error; } finally { clearTimeout(timeoutId!); } } throw new Error("Token refresh failed after retries"); } let refreshPromise: Promise | null = null; async function doTokenRefresh(): Promise { const current = await getAccessToken(); if (current && !shouldRefreshAccessToken(current)) return current; const refreshToken = await getRefreshToken(); if (!refreshToken) { await clearTokens(); throw new Error("No refresh token available"); } const tokens = await refreshWithRetry(refreshToken); await storeTokens(tokens.access_token, tokens.refresh_token); return tokens.access_token; } function handleTokenRefresh(): Promise { if (refreshPromise) return refreshPromise; const innerPromise = typeof navigator.locks?.request === "function" ? navigator.locks .request("rf-token-refresh", doTokenRefresh) .then((t) => t) : doTokenRefresh(); const promise = innerPromise .catch(async (error: unknown) => { await clearTokens(); const status = (error as { status?: number }).status; if (status === 401) { throw new Error("Session expired. Please sign in again."); } throw new Error("Session refresh failed. Please sign in again."); }) .finally(() => { refreshPromise = null; }); refreshPromise = promise; return promise; } export async function getToken(): Promise { const accessToken = await getAccessToken(); if (!accessToken) { if (!(await getRefreshToken())) throw new Error("Not authenticated"); return handleTokenRefresh(); } if (shouldRefreshAccessToken(accessToken)) return handleTokenRefresh(); return accessToken; } export async function triggerRefresh(): Promise { await clearAccessToken(); return handleTokenRefresh(); } ================================================ FILE: packages/remote-web/src/shared/lib/auth.ts ================================================ const DB_NAME = "rf-auth"; const STORE_NAME = "tokens"; const ACCESS_TOKEN_KEY = "access_token"; const REFRESH_TOKEN_KEY = "refresh_token"; export const AUTH_CHANGED_EVENT = "remote-auth-changed"; function emitAuthChanged(): void { if (typeof window !== "undefined") { window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); } } function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = () => { request.result.createObjectStore(STORE_NAME); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function get(key: string): Promise { return openDB().then( (db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); let value: string | null = null; const req = tx.objectStore(STORE_NAME).get(key); req.onsuccess = () => { value = (req.result as string) ?? null; }; req.onerror = () => reject(req.error); tx.oncomplete = () => resolve(value); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }), ); } function put(key: string, value: string): Promise { return openDB().then( (db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const req = tx.objectStore(STORE_NAME).put(value, key); req.onerror = () => reject(req.error); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }), ); } function del(key: string): Promise { return openDB().then( (db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const req = tx.objectStore(STORE_NAME).delete(key); req.onerror = () => reject(req.error); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }), ); } export async function storeTokens( accessToken: string, refreshToken: string, ): Promise { await put(ACCESS_TOKEN_KEY, accessToken); await put(REFRESH_TOKEN_KEY, refreshToken); emitAuthChanged(); } export function getAccessToken(): Promise { return get(ACCESS_TOKEN_KEY); } export function getRefreshToken(): Promise { return get(REFRESH_TOKEN_KEY); } export async function clearAccessToken(): Promise { await del(ACCESS_TOKEN_KEY); } export async function clearTokens(): Promise { await del(ACCESS_TOKEN_KEY); await del(REFRESH_TOKEN_KEY); emitAuthChanged(); } export async function isLoggedIn(): Promise { const [access, refresh] = await Promise.all([ getAccessToken(), getRefreshToken(), ]); return access !== null && refresh !== null; } ================================================ FILE: packages/remote-web/src/shared/lib/pkce.ts ================================================ function base64UrlEncode(array: Uint8Array): string { const base64 = btoa(String.fromCharCode(...array)); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } function bytesToHex(bytes: Uint8Array): string { let out = ""; for (let i = 0; i < bytes.length; i++) { out += bytes[i].toString(16).padStart(2, "0"); } return out; } export function generateVerifier(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64UrlEncode(array); } export async function generateChallenge(verifier: string): Promise { const data = new TextEncoder().encode(verifier); const hash = await crypto.subtle.digest("SHA-256", data); return bytesToHex(new Uint8Array(hash)); } const VERIFIER_KEY = "oauth_verifier"; const INVITATION_TOKEN_KEY = "invitation_token"; export function storeVerifier(verifier: string): void { sessionStorage.setItem(VERIFIER_KEY, verifier); } export function retrieveVerifier(): string | null { return sessionStorage.getItem(VERIFIER_KEY); } export function clearVerifier(): void { sessionStorage.removeItem(VERIFIER_KEY); } export function storeInvitationToken(token: string): void { sessionStorage.setItem(INVITATION_TOKEN_KEY, token); } export function retrieveInvitationToken(): string | null { return sessionStorage.getItem(INVITATION_TOKEN_KEY); } export function clearInvitationToken(): void { sessionStorage.removeItem(INVITATION_TOKEN_KEY); } ================================================ FILE: packages/remote-web/src/shared/lib/relay/activeHostContext.ts ================================================ let activeRelayHostId: string | null = null; export function setActiveRelayHostId(hostId: string | null): void { activeRelayHostId = hostId; } export function getActiveRelayHostId(): string | null { return activeRelayHostId; } ================================================ FILE: packages/remote-web/src/shared/lib/relay/bytes.ts ================================================ export const TEXT_ENCODER = new TextEncoder(); export const TEXT_DECODER = new TextDecoder(); export function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (const value of bytes) { binary += String.fromCharCode(value); } return btoa(binary); } export function base64ToBytes(value: string): Uint8Array { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ) as ArrayBuffer; } export async function sha256Base64(bytes: Uint8Array): Promise { const hashBuffer = await crypto.subtle.digest( "SHA-256", toArrayBuffer(bytes), ); return bytesToBase64(new Uint8Array(hashBuffer)); } ================================================ FILE: packages/remote-web/src/shared/lib/relay/context.ts ================================================ import { type PairedRelayHost, listPairedRelayHosts, savePairedRelayHost, subscribeRelayPairingChanges, } from "@/shared/lib/relayPairingStorage"; import { createRelaySession } from "@/shared/lib/remoteApi"; import { createRelaySessionAuthCode, establishRelaySessionBaseUrl, getRelayApiUrl, refreshRelaySigningSession, } from "@/shared/lib/relayBackendApi"; import { buildRelaySigningSessionRefreshPayload } from "@/shared/lib/relaySigningSessionRefresh"; import type { RelayHostContext } from "@remote/shared/lib/relay/types"; const relaySessionBaseUrlCache = new Map>(); subscribeRelayPairingChanges(({ hostId }) => { relaySessionBaseUrlCache.delete(hostId); }); export async function resolveRelayHostContext( hostId: string, ): Promise { const pairedHost = await findPairedHost(hostId); if (!pairedHost) { throw new Error( "This host is not paired with your browser. Pair it in Relay settings.", ); } if (!pairedHost.signing_session_id) { throw new Error( "This host pairing is outdated. Re-pair it in Relay settings.", ); } const relaySessionBaseUrl = await getRelaySessionBaseUrl(hostId); return { pairedHost, relaySessionBaseUrl, }; } export function invalidateRelaySessionBaseUrl(hostId: string): void { relaySessionBaseUrlCache.delete(hostId); } export async function tryRefreshRelayHostSigningSession( context: RelayHostContext, ): Promise { const clientId = context.pairedHost.client_id; if (!clientId) { return null; } try { const payload = await buildRelaySigningSessionRefreshPayload( clientId, context.pairedHost.private_key_jwk, ); const refreshed = await refreshRelaySigningSession( context.relaySessionBaseUrl, payload, ); const updatedPairedHost: PairedRelayHost = { ...context.pairedHost, signing_session_id: refreshed.signing_session_id, }; await savePairedRelayHost(updatedPairedHost); return { ...context, pairedHost: updatedPairedHost, }; } catch (error) { console.warn("Failed to refresh relay signing session", error); return null; } } async function getRelaySessionBaseUrl(hostId: string): Promise { const cached = relaySessionBaseUrlCache.get(hostId); if (cached) { return cached; } const created = createRelaySessionBaseUrl(hostId).catch((error) => { relaySessionBaseUrlCache.delete(hostId); throw error; }); relaySessionBaseUrlCache.set(hostId, created); return created; } async function createRelaySessionBaseUrl(hostId: string): Promise { const relaySession = await createRelaySession(hostId); const authCode = await createRelaySessionAuthCode(relaySession.id); const relayApiUrl = getRelayApiUrl(); return establishRelaySessionBaseUrl(relayApiUrl, hostId, authCode.code); } async function findPairedHost(hostId: string): Promise { const pairedHosts = await listPairedRelayHosts(); return pairedHosts.find((host) => host.host_id === hostId) ?? null; } ================================================ FILE: packages/remote-web/src/shared/lib/relay/http.ts ================================================ import { buildSignedHeaders, CONTENT_TYPE_HEADER, } from "@remote/shared/lib/relay/signing"; import type { RelayHostContext } from "@remote/shared/lib/relay/types"; export function isAuthFailureStatus(status: number): boolean { return status === 401 || status === 403; } export async function sendRelayHostRequest( context: RelayHostContext, params: { normalizedPath: string; method: string; body: BodyInit | undefined; bodyBytes: Uint8Array; contentType: string | null; requestInit: RequestInit; }, ): Promise { const headers = await buildSignedHeaders( context.pairedHost, params.method, params.normalizedPath, params.bodyBytes, params.requestInit.headers, ); if (params.contentType && !headers.has(CONTENT_TYPE_HEADER)) { headers.set(CONTENT_TYPE_HEADER, params.contentType); } return fetch(`${context.relaySessionBaseUrl}${params.normalizedPath}`, { ...params.requestInit, body: params.body, headers, credentials: "include", }); } ================================================ FILE: packages/remote-web/src/shared/lib/relay/keyCache.ts ================================================ import type { PairedRelayHost } from "@/shared/lib/relayPairingStorage"; import { subscribeRelayPairingChanges } from "@/shared/lib/relayPairingStorage"; import { base64ToBytes, toArrayBuffer } from "@remote/shared/lib/relay/bytes"; const signingKeyCache = new Map(); const serverVerifyKeyCache = new Map(); subscribeRelayPairingChanges(({ hostId }) => { clearRelayHostCryptoCaches(hostId); }); export async function getSigningKey( pairedHost: PairedRelayHost, ): Promise { const signingSessionId = pairedHost.signing_session_id; if (!signingSessionId) { throw new Error("Missing signing session for paired host."); } const cacheKey = pairedHost.host_id; const cachedKey = signingKeyCache.get(cacheKey); if (cachedKey) { return cachedKey; } const importedKey = await crypto.subtle.importKey( "jwk", pairedHost.private_key_jwk, { name: "Ed25519" }, false, ["sign"], ); signingKeyCache.set(cacheKey, importedKey); return importedKey; } export async function getServerVerifyKey( pairedHost: PairedRelayHost, ): Promise { const signingSessionId = pairedHost.signing_session_id; if (!signingSessionId) { throw new Error("Missing signing session for paired host."); } const cacheKey = pairedHost.host_id; const cachedKey = serverVerifyKeyCache.get(cacheKey); if (cachedKey) { return cachedKey; } const serverPublicKeyB64 = pairedHost.server_public_key_b64; if (!serverPublicKeyB64) { throw new Error("Missing server signing key for paired host."); } const importedKey = await crypto.subtle.importKey( "raw", toArrayBuffer(base64ToBytes(serverPublicKeyB64)), { name: "Ed25519" }, false, ["verify"], ); serverVerifyKeyCache.set(cacheKey, importedKey); return importedKey; } export function clearRelayHostCryptoCaches(hostId: string): void { signingKeyCache.delete(hostId); serverVerifyKeyCache.delete(hostId); } ================================================ FILE: packages/remote-web/src/shared/lib/relay/routing.ts ================================================ export function isWorkspaceRoutePath(pathname: string): boolean { const segments = pathname.split("/").filter(Boolean); if (segments[0] !== "hosts" || !segments[1]) { return false; } if (segments[2] === "workspaces") { return true; } if (segments[2] !== "projects" || !segments[3]) { return false; } const isIssueWorkspacePath = segments[4] === "issues" && !!segments[5] && segments[6] === "workspaces" && !!segments[7]; const isProjectWorkspaceCreatePath = segments[4] === "workspaces" && segments[5] === "create" && !!segments[6]; return isIssueWorkspacePath || isProjectWorkspaceCreatePath; } export function parseRelayHostIdFromPathname(pathname: string): string | null { const segments = pathname.split("/").filter(Boolean); const hostsSegmentIndex = segments.indexOf("hosts"); if (hostsSegmentIndex === -1) { return null; } return segments[hostsSegmentIndex + 1] ?? null; } export function resolveRelayHostIdForCurrentPage(): string | null { if (typeof window === "undefined") { return null; } return parseRelayHostIdFromPathname(window.location.pathname); } export function shouldRelayApiPath(pathAndQuery: string): boolean { const [path] = pathAndQuery.split("?"); if (!path.startsWith("/api/")) { return false; } return !path.startsWith("/api/remote/"); } export function normalizePath(pathAndQuery: string): string { return pathAndQuery.startsWith("/") ? pathAndQuery : `/${pathAndQuery}`; } export function toPathAndQuery(pathOrUrl: string): string { if (/^https?:\/\//i.test(pathOrUrl) || /^wss?:\/\//i.test(pathOrUrl)) { const url = new URL(pathOrUrl); return `${url.pathname}${url.search}`; } return pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`; } export function openBrowserWebSocket(pathOrUrl: string): WebSocket { if (/^wss?:\/\//i.test(pathOrUrl)) { return new WebSocket(pathOrUrl); } if (/^https?:\/\//i.test(pathOrUrl)) { return new WebSocket(pathOrUrl.replace(/^http/i, "ws")); } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const normalizedPath = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`; return new WebSocket(`${protocol}//${window.location.host}${normalizedPath}`); } ================================================ FILE: packages/remote-web/src/shared/lib/relay/signing.ts ================================================ import type { PairedRelayHost } from "@/shared/lib/relayPairingStorage"; import { bytesToBase64, sha256Base64, TEXT_ENCODER, toArrayBuffer, } from "@remote/shared/lib/relay/bytes"; import { getSigningKey } from "@remote/shared/lib/relay/keyCache"; import type { NormalizedRelayRequestBody, RelaySignature, } from "@remote/shared/lib/relay/types"; export const CONTENT_TYPE_HEADER = "Content-Type"; const SIGNING_SESSION_HEADER = "x-vk-sig-session"; const TIMESTAMP_HEADER = "x-vk-sig-ts"; const NONCE_HEADER = "x-vk-sig-nonce"; const REQUEST_SIGNATURE_HEADER = "x-vk-sig-signature"; const EMPTY_BYTES = new Uint8Array(); // Placeholder origin used only to construct/parse relative URLs. Never fetched. const URL_PARSE_BASE = "https://example.invalid"; export async function buildSignedHeaders( pairedHost: PairedRelayHost, method: string, pathAndQuery: string, bodyBytes: Uint8Array, incomingHeaders?: HeadersInit, ): Promise { const signature = await buildRelaySignature( pairedHost, method, pathAndQuery, bodyBytes, ); const headers = new Headers(incomingHeaders); headers.set(SIGNING_SESSION_HEADER, signature.signingSessionId); headers.set(TIMESTAMP_HEADER, String(signature.timestamp)); headers.set(NONCE_HEADER, signature.nonce); headers.set(REQUEST_SIGNATURE_HEADER, signature.signature); return headers; } export async function buildRelaySignature( pairedHost: PairedRelayHost, method: string, pathAndQuery: string, bodyBytes: Uint8Array, ): Promise { const signingSessionId = pairedHost.signing_session_id; if (!signingSessionId) { throw new Error( "This host pairing is missing signing metadata. Re-pair it.", ); } const timestamp = Math.floor(Date.now() / 1000); const nonce = crypto.randomUUID().replace(/-/g, ""); const bodyHashB64 = await sha256Base64(bodyBytes); const message = [ "v1", String(timestamp), method.toUpperCase(), pathAndQuery, signingSessionId, nonce, bodyHashB64, ].join("|"); const signingKey = await getSigningKey(pairedHost); const signature = await crypto.subtle.sign( "Ed25519", signingKey, toArrayBuffer(TEXT_ENCODER.encode(message)), ); return { signingSessionId, timestamp, nonce, signature: bytesToBase64(new Uint8Array(signature)), }; } export async function normalizeRequestBody( body: BodyInit | null | undefined, ): Promise { if (body == null) { return { body: undefined, bodyBytes: EMPTY_BYTES, contentType: null }; } if (typeof body === "string") { return { body, bodyBytes: TEXT_ENCODER.encode(body), contentType: "text/plain;charset=UTF-8", }; } const probeRequest = new Request(URL_PARSE_BASE, { method: "POST", body, }); const serializedBody = new Uint8Array(await probeRequest.arrayBuffer()); return { // Use the exact serialized bytes for both signing and transport. body: serializedBody, bodyBytes: serializedBody, contentType: probeRequest.headers.get(CONTENT_TYPE_HEADER), }; } export function appendSignatureToPath( pathAndQuery: string, signature: RelaySignature, ): string { const url = new URL(pathAndQuery, URL_PARSE_BASE); url.searchParams.set(SIGNING_SESSION_HEADER, signature.signingSessionId); url.searchParams.set(TIMESTAMP_HEADER, String(signature.timestamp)); url.searchParams.set(NONCE_HEADER, signature.nonce); url.searchParams.set(REQUEST_SIGNATURE_HEADER, signature.signature); return `${url.pathname}${url.search}`; } ================================================ FILE: packages/remote-web/src/shared/lib/relay/types.ts ================================================ import type { PairedRelayHost } from "@/shared/lib/relayPairingStorage"; export interface RelaySignature { signingSessionId: string; timestamp: number; nonce: string; signature: string; } export interface RelayHostContext { pairedHost: PairedRelayHost; relaySessionBaseUrl: string; } export type RelayWsMessageType = "text" | "binary" | "ping" | "pong" | "close"; export interface RelaySignedWsEnvelope { version: number; seq: number; msg_type: RelayWsMessageType; payload_b64: string; signature_b64: string; } export interface RelayWsSigningContext { signingSessionId: string; requestNonce: string; inboundSeq: number; outboundSeq: number; signingKey: CryptoKey; serverVerifyKey: CryptoKey; } export interface NormalizedRelayRequestBody { body: BodyInit | undefined; bodyBytes: Uint8Array; contentType: string | null; } ================================================ FILE: packages/remote-web/src/shared/lib/relay/ws.ts ================================================ import type { PairedRelayHost } from "@/shared/lib/relayPairingStorage"; import { base64ToBytes, bytesToBase64, sha256Base64, TEXT_DECODER, TEXT_ENCODER, toArrayBuffer, } from "@remote/shared/lib/relay/bytes"; import { getServerVerifyKey, getSigningKey, } from "@remote/shared/lib/relay/keyCache"; import type { RelaySignature, RelaySignedWsEnvelope, RelayWsMessageType, RelayWsSigningContext, } from "@remote/shared/lib/relay/types"; const WS_ENVELOPE_VERSION = 1; export async function createRelayWsSigningContext( pairedHost: PairedRelayHost, requestSignature: RelaySignature, ): Promise { const [signingKey, serverVerifyKey] = await Promise.all([ getSigningKey(pairedHost), getServerVerifyKey(pairedHost), ]); return { signingSessionId: requestSignature.signingSessionId, requestNonce: requestSignature.nonce, inboundSeq: 0, outboundSeq: 0, signingKey, serverVerifyKey, }; } export function createRelaySignedWebSocket( rawSocket: WebSocket, signingContext: RelayWsSigningContext, ): WebSocket { return new RelaySignedWebSocket( rawSocket, signingContext, ) as unknown as WebSocket; } class RelaySignedWebSocket extends EventTarget { onopen: WebSocket["onopen"] = null; onerror: WebSocket["onerror"] = null; onclose: WebSocket["onclose"] = null; onmessage: WebSocket["onmessage"] = null; private outboundQueue: Promise = Promise.resolve(); private inboundQueue: Promise = Promise.resolve(); private binaryTypeValue: BinaryType = "blob"; constructor( private readonly rawSocket: WebSocket, private readonly signingContext: RelayWsSigningContext, ) { super(); this.rawSocket.binaryType = "arraybuffer"; this.attachRawSocketListeners(); } get url(): string { return this.rawSocket.url; } get protocol(): string { return this.rawSocket.protocol; } get extensions(): string { return this.rawSocket.extensions; } get bufferedAmount(): number { return this.rawSocket.bufferedAmount; } get readyState(): number { return this.rawSocket.readyState; } get binaryType(): BinaryType { return this.binaryTypeValue; } set binaryType(value: BinaryType) { this.binaryTypeValue = value; } send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { this.outboundQueue = this.outboundQueue .then(async () => { if (this.rawSocket.readyState !== WebSocket.OPEN) { return; } const { msgType, payload } = await normalizeOutboundWsPayload(data); const envelope = await buildRelayWsEnvelope( this.signingContext, msgType, payload, ); this.rawSocket.send(JSON.stringify(envelope)); }) .catch((error) => { this.emitProtocolError(error); }); } close(code?: number, reason?: string): void { this.rawSocket.close(code, reason); } private attachRawSocketListeners(): void { this.rawSocket.addEventListener("open", () => { this.emitOpen(); }); this.rawSocket.addEventListener("message", (event) => { this.inboundQueue = this.inboundQueue .then(async () => { const envelope = await decodeRelayWsEnvelope( this.signingContext, event.data, ); await this.forwardDecodedFrame(envelope.msg_type, envelope.payload); }) .catch((error) => { this.emitProtocolError(error); }); }); this.rawSocket.addEventListener("error", () => { this.emitError(); }); this.rawSocket.addEventListener("close", (event) => { this.emitClose(event.code, event.reason, event.wasClean); }); } private async forwardDecodedFrame( msgType: RelayWsMessageType, payload: Uint8Array, ): Promise { switch (msgType) { case "text": this.emitMessage(TEXT_DECODER.decode(payload)); return; case "binary": this.emitMessage(await this.toBinaryMessageData(payload)); return; case "close": { const closePayload = decodeClosePayload(payload); if (closePayload.code == null) { this.close(); return; } try { this.close(closePayload.code, closePayload.reason); } catch { this.close(); } return; } case "ping": case "pong": return; } } private async toBinaryMessageData( payload: Uint8Array, ): Promise { if (this.binaryTypeValue === "arraybuffer") { return toArrayBuffer(payload); } return new Blob([toArrayBuffer(payload)]); } private emitOpen(): void { const event = new Event("open"); this.onopen?.call(this.asWebSocket(), event); this.dispatchEvent(event); } private emitError(): void { const event = new Event("error"); this.onerror?.call(this.asWebSocket(), event); this.dispatchEvent(event); } private emitClose(code: number, reason: string, wasClean: boolean): void { const event = new CloseEvent("close", { code, reason, wasClean }); this.onclose?.call(this.asWebSocket(), event); this.dispatchEvent(event); } private emitMessage(data: string | ArrayBuffer | Blob): void { const event = new MessageEvent("message", { data }); this.onmessage?.call(this.asWebSocket(), event); this.dispatchEvent(event); } private emitProtocolError(error: unknown): void { console.error("Failed to process relay WebSocket frame:", error); this.emitError(); if ( this.rawSocket.readyState === WebSocket.OPEN || this.rawSocket.readyState === WebSocket.CONNECTING ) { this.rawSocket.close(1002, "Invalid relay frame"); } } private asWebSocket(): WebSocket { return this as unknown as WebSocket; } } async function normalizeOutboundWsPayload( data: string | ArrayBufferLike | Blob | ArrayBufferView, ): Promise<{ msgType: RelayWsMessageType; payload: Uint8Array }> { if (typeof data === "string") { return { msgType: "text", payload: TEXT_ENCODER.encode(data) }; } if (data instanceof Blob) { return { msgType: "binary", payload: new Uint8Array(await data.arrayBuffer()), }; } if (ArrayBuffer.isView(data)) { return { msgType: "binary", payload: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), }; } if (data instanceof ArrayBuffer) { return { msgType: "binary", payload: new Uint8Array(data) }; } throw new Error("Unsupported WebSocket payload type."); } async function decodeRelayWsEnvelope( signingContext: RelayWsSigningContext, rawData: unknown, ): Promise { const rawFrame = await decodeWsFrameBytes(rawData); const parsedEnvelope = parseRelayWsEnvelope(rawFrame); if (parsedEnvelope.version !== WS_ENVELOPE_VERSION) { throw new Error("Unsupported relay WS envelope version."); } const expectedSeq = signingContext.inboundSeq + 1; if (parsedEnvelope.seq !== expectedSeq) { throw new Error( `Invalid relay WS sequence: expected ${expectedSeq}, got ${parsedEnvelope.seq}.`, ); } const payload = base64ToBytes(parsedEnvelope.payload_b64); const signatureBytes = base64ToBytes(parsedEnvelope.signature_b64); const signingInput = await buildRelayWsSigningInput( signingContext.signingSessionId, signingContext.requestNonce, parsedEnvelope.seq, parsedEnvelope.msg_type, payload, ); const isValid = await crypto.subtle.verify( "Ed25519", signingContext.serverVerifyKey, toArrayBuffer(signatureBytes), toArrayBuffer(TEXT_ENCODER.encode(signingInput)), ); if (!isValid) { throw new Error("Invalid relay WS frame signature."); } signingContext.inboundSeq = parsedEnvelope.seq; return { ...parsedEnvelope, payload }; } async function buildRelayWsEnvelope( signingContext: RelayWsSigningContext, msgType: RelayWsMessageType, payload: Uint8Array, ): Promise { const nextSeq = signingContext.outboundSeq + 1; const signingInput = await buildRelayWsSigningInput( signingContext.signingSessionId, signingContext.requestNonce, nextSeq, msgType, payload, ); const signature = await crypto.subtle.sign( "Ed25519", signingContext.signingKey, toArrayBuffer(TEXT_ENCODER.encode(signingInput)), ); signingContext.outboundSeq = nextSeq; return { version: WS_ENVELOPE_VERSION, seq: nextSeq, msg_type: msgType, payload_b64: bytesToBase64(payload), signature_b64: bytesToBase64(new Uint8Array(signature)), }; } async function buildRelayWsSigningInput( signingSessionId: string, requestNonce: string, seq: number, msgType: RelayWsMessageType, payload: Uint8Array, ): Promise { const payloadHashB64 = await sha256Base64(payload); return [ "v1", signingSessionId, requestNonce, String(seq), msgType, payloadHashB64, ].join("|"); } async function decodeWsFrameBytes(rawData: unknown): Promise { if (typeof rawData === "string") { return TEXT_ENCODER.encode(rawData); } if (rawData instanceof Blob) { return new Uint8Array(await rawData.arrayBuffer()); } if (ArrayBuffer.isView(rawData)) { return new Uint8Array( rawData.buffer, rawData.byteOffset, rawData.byteLength, ); } if (rawData instanceof ArrayBuffer) { return new Uint8Array(rawData); } throw new Error("Unsupported relay WebSocket frame."); } function parseRelayWsEnvelope(rawFrame: Uint8Array): RelaySignedWsEnvelope { let parsed: unknown; try { parsed = JSON.parse(TEXT_DECODER.decode(rawFrame)); } catch { throw new Error("Invalid relay WS envelope JSON."); } if (typeof parsed !== "object" || parsed == null) { throw new Error("Invalid relay WS envelope."); } const envelope = parsed as Partial; if ( typeof envelope.version !== "number" || typeof envelope.seq !== "number" || !isRelayWsMessageType(envelope.msg_type) || typeof envelope.payload_b64 !== "string" || typeof envelope.signature_b64 !== "string" ) { throw new Error("Invalid relay WS envelope shape."); } return { version: envelope.version, seq: envelope.seq, msg_type: envelope.msg_type, payload_b64: envelope.payload_b64, signature_b64: envelope.signature_b64, }; } function isRelayWsMessageType(value: unknown): value is RelayWsMessageType { return ( value === "text" || value === "binary" || value === "ping" || value === "pong" || value === "close" ); } function decodeClosePayload(payload: Uint8Array): { code?: number; reason?: string; } { if (payload.length === 0) { return {}; } if (payload.length < 2) { throw new Error("Invalid relay WS close payload."); } const code = (payload[0] << 8) | payload[1]; const reason = payload.length > 2 ? TEXT_DECODER.decode(payload.slice(2)) : ""; return { code, reason }; } ================================================ FILE: packages/remote-web/src/shared/lib/relayHostApi.ts ================================================ import { invalidateRelaySessionBaseUrl, resolveRelayHostContext, tryRefreshRelayHostSigningSession, } from "@remote/shared/lib/relay/context"; import { getActiveRelayHostId } from "@remote/shared/lib/relay/activeHostContext"; import { isAuthFailureStatus, sendRelayHostRequest, } from "@remote/shared/lib/relay/http"; import { isWorkspaceRoutePath, normalizePath, openBrowserWebSocket, resolveRelayHostIdForCurrentPage, shouldRelayApiPath, toPathAndQuery, } from "@remote/shared/lib/relay/routing"; import { appendSignatureToPath, buildRelaySignature, normalizeRequestBody, } from "@remote/shared/lib/relay/signing"; import { createRelaySignedWebSocket, createRelayWsSigningContext, } from "@remote/shared/lib/relay/ws"; const EMPTY_BYTES = new Uint8Array(); export { isWorkspaceRoutePath }; export async function requestLocalApiViaRelay( pathOrUrl: string, requestInit: RequestInit = {}, ): Promise { const pathAndQuery = toPathAndQuery(pathOrUrl); if (!shouldRelayApiPath(pathAndQuery)) { return fetch(pathOrUrl, requestInit); } const hostId = resolveRelayHostIdForCurrentPage() ?? getActiveRelayHostId(); if (!hostId) { throw new Error( "Host context is required for local API requests. Navigate under /hosts/{hostId}/...", ); } return requestRelayHostApi(hostId, pathAndQuery, requestInit); } export async function openLocalApiWebSocketViaRelay( pathOrUrl: string, ): Promise { const pathAndQuery = toPathAndQuery(pathOrUrl); if (!shouldRelayApiPath(pathAndQuery)) { return openBrowserWebSocket(pathOrUrl); } const hostId = resolveRelayHostIdForCurrentPage() ?? getActiveRelayHostId(); if (!hostId) { throw new Error( "Host context is required for local API WebSocket requests. Navigate under /hosts/{hostId}/...", ); } return openRelayHostWebSocket(hostId, pathAndQuery); } export async function requestRelayHostApi( hostId: string, pathOrUrl: string, requestInit: RequestInit = {}, ): Promise { const pathAndQuery = toPathAndQuery(pathOrUrl); const normalizedPath = normalizePath(pathAndQuery); const method = (requestInit.method ?? "GET").toUpperCase(); const { body, bodyBytes, contentType } = await normalizeRequestBody( requestInit.body, ); const context = await resolveRelayHostContext(hostId); const initialResponse = await sendRelayHostRequest(context, { normalizedPath, method, body, bodyBytes, contentType, requestInit, }); if (!isAuthFailureStatus(initialResponse.status)) { return initialResponse; } invalidateRelaySessionBaseUrl(hostId); const refreshedContext = await tryRefreshRelayHostSigningSession(context); if (!refreshedContext) { return initialResponse; } const retryResponse = await sendRelayHostRequest(refreshedContext, { normalizedPath, method, body, bodyBytes, contentType, requestInit, }); if (isAuthFailureStatus(retryResponse.status)) { invalidateRelaySessionBaseUrl(hostId); } return retryResponse; } export async function openRelayHostWebSocket( hostId: string, pathOrUrl: string, ): Promise { const baseContext = await resolveRelayHostContext(hostId); const context = (await tryRefreshRelayHostSigningSession(baseContext)) ?? baseContext; const pathAndQuery = toPathAndQuery(pathOrUrl); const normalizedPath = normalizePath(pathAndQuery); const signature = await buildRelaySignature( context.pairedHost, "GET", normalizedPath, EMPTY_BYTES, ); const signedPath = appendSignatureToPath(normalizedPath, signature); const wsUrl = `${context.relaySessionBaseUrl}${signedPath}`.replace( /^http/i, "ws", ); const signingContext = await createRelayWsSigningContext( context.pairedHost, signature, ); return createRelaySignedWebSocket(new WebSocket(wsUrl), signingContext); } ================================================ FILE: packages/remote-web/src/shared/lib/route-auth.ts ================================================ import { redirect, type ParsedLocation } from "@tanstack/react-router"; import { isLoggedIn } from "@remote/shared/lib/auth"; type RouteLocation = Pick; function toNextPath({ pathname, searchStr, hash }: RouteLocation): string { return `${pathname}${searchStr}${hash}`; } export async function requireAuthenticated(location: RouteLocation) { if (await isLoggedIn()) { return; } throw redirect({ to: "/account", search: { next: toNextPath(location), }, }); } export async function redirectAuthenticatedToHome() { if (await isLoggedIn()) { throw redirect({ to: "/" }); } } ================================================ FILE: packages/remote-web/src/shared/stores/useMobileWorkspaceTitle.ts ================================================ import { create } from "zustand"; interface MobileWorkspaceTitleStore { title: string | null; setTitle: (title: string | null) => void; } export const useMobileWorkspaceTitle = create( (set) => ({ title: null, setTitle: (title) => set({ title }), }), ); ================================================ FILE: packages/remote-web/src/shared/types/virtual-executor-schemas.d.ts ================================================ declare module "virtual:executor-schemas" { import type { BaseCodingAgent } from "@/shared/types"; type RJSFSchema = Record; const schemas: Record; export { schemas }; export default schemas; } ================================================ FILE: packages/remote-web/src/vite-env.d.ts ================================================ /// interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; readonly VITE_RELAY_API_BASE_URL: string; readonly VITE_APP_BASE_URL: string; readonly VITE_PUBLIC_POSTHOG_KEY: string; readonly VITE_PUBLIC_POSTHOG_HOST: string; } interface ImportMeta { readonly env: ImportMetaEnv; } declare const __APP_VERSION__: string; ================================================ FILE: packages/remote-web/tailwind.config.cjs ================================================ module.exports = { content: [ "./index.html", "./src/**/*.{js,jsx,ts,tsx}", "../web-core/src/**/*.{js,jsx,ts,tsx}", ], }; ================================================ FILE: packages/remote-web/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@remote/*": ["./src/*"], "@/*": ["../web-core/src/*"], "shared/*": ["../../shared/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: packages/remote-web/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: packages/remote-web/vite.config.ts ================================================ import fs from "fs"; import path from "path"; import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import pkg from "./package.json"; function executorSchemasPlugin(): Plugin { const VIRTUAL_ID = "virtual:executor-schemas"; const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`; return { name: "executor-schemas-plugin", resolveId(id) { if (id === VIRTUAL_ID) { return RESOLVED_VIRTUAL_ID; } return null; }, load(id) { if (id !== RESOLVED_VIRTUAL_ID) { return null; } const schemasDir = path.resolve(__dirname, "../../shared/schemas"); const files = fs.existsSync(schemasDir) ? fs.readdirSync(schemasDir).filter((file) => file.endsWith(".json")) : []; const imports: string[] = []; const entries: string[] = []; files.forEach((file, index) => { const varName = `__schema_${index}`; const importPath = `shared/schemas/${file}`; const key = file.replace(/\.json$/, "").toUpperCase(); imports.push(`import ${varName} from "${importPath}";`); entries.push(` "${key}": ${varName}`); }); return ` ${imports.join("\n")} export const schemas = { ${entries.join(",\n")} }; export default schemas; `; }, }; } export default defineConfig({ publicDir: path.resolve(__dirname, "../public"), define: { __APP_VERSION__: JSON.stringify(pkg.version), }, plugins: [ tanstackRouter({ target: "react", autoCodeSplitting: false, }), react({ babel: { plugins: [ [ "babel-plugin-react-compiler", { target: "18", sources: [ path.resolve(__dirname, "src"), path.resolve(__dirname, "../web-core/src"), ], environment: { enableResetCacheOnSourceFileChanges: true, }, }, ], ], }, }), executorSchemasPlugin(), ], resolve: { alias: [ { find: "@remote", replacement: path.resolve(__dirname, "src"), }, { find: /^@\//, replacement: `${path.resolve(__dirname, "../web-core/src")}/`, }, { find: "shared", replacement: path.resolve(__dirname, "../../shared"), }, ], }, server: { port: 3002, allowedHosts: [ ".trycloudflare.com", // allow all cloudflared tunnels ], fs: { allow: [path.resolve(__dirname, "."), path.resolve(__dirname, "../..")], }, }, }); ================================================ FILE: packages/ui/.eslintrc.cjs ================================================ module.exports = { root: true, env: { browser: true, es2020: true, }, parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module', project: './tsconfig.json', tsconfigRootDir: __dirname, }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'prettier', ], plugins: ['@typescript-eslint', 'react-hooks', 'unused-imports'], ignorePatterns: ['dist'], rules: { 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [ 'error', { vars: 'all', args: 'after-used', ignoreRestSiblings: false, }, ], '@typescript-eslint/no-explicit-any': 'warn', }, }; ================================================ FILE: packages/ui/README.md ================================================ # @vibe/ui Shared UI package for reusable web app primitives. ## Scope (initial) - Package scaffold and exports. - Shared utility helpers (`cn`). - Tailwind class generation remains configured in `packages/local-web/tailwind.new.config.js`. ## Notes - Tailwind scanning for this package is enabled from `packages/local-web/tailwind.new.config.js` via: `../ui/src/**/*.{ts,tsx}`. - The app-level stylesheet remains `packages/web-core/src/styles/new/index.css`. ================================================ FILE: packages/ui/package.json ================================================ { "name": "@vibe/ui", "private": true, "version": "0.1.0", "type": "module", "scripts": { "check": "tsc --noEmit -p tsconfig.json", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --config ../../packages/local-web/.prettierrc.json --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --config ../../packages/local-web/.prettierrc.json --check \"src/**/*.{ts,tsx,js,jsx,json,md}\"" }, "sideEffects": false, "exports": { "./components/*": "./src/components/*.tsx", "./lib/*": "./src/lib/*.ts" }, "dependencies": { "@ebay/nice-modal-react": "^1.2.13", "@hello-pangea/dnd": "^18.0.1", "@lexical/code": "^0.36.2", "@lexical/link": "^0.36.2", "@lexical/list": "^0.36.2", "@lexical/markdown": "^0.36.2", "@lexical/react": "^0.36.2", "@lexical/table": "^0.36.2", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.0.8", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "developer-icons": "^6.0.4", "lexical": "^0.36.2", "lucide-react": "^0.541.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "^5.1.0", "react-i18next": "^15.7.4", "react-virtuoso": "^4.14.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.55.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^4.1.4", "prettier": "^3.6.1", "typescript": "^5.9.2" } } ================================================ FILE: packages/ui/src/components/Accordion.tsx ================================================ import * as React from 'react'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; import { cn } from '../lib/cn'; const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AccordionItem.displayName = AccordionPrimitive.Item.displayName; interface AccordionTriggerProps extends React.ComponentPropsWithoutRef { sticky?: boolean; } const AccordionTrigger = React.forwardRef< React.ElementRef, AccordionTriggerProps >(({ className, children, sticky = false, ...props }, ref) => ( {children} )); AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AccordionContent.displayName = AccordionPrimitive.Content.displayName; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; ================================================ FILE: packages/ui/src/components/Alert.tsx ================================================ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../lib/cn'; const alertVariants = cva( 'relative w-full border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground text-sm', { variants: { variant: { default: 'bg-background text-foreground', destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', success: 'border-success/50 bg-success/10 text-success-foreground [&>svg]:text-success', }, }, defaultVariants: { variant: 'default', }, } ); const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => (
)); Alert.displayName = 'Alert'; const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AlertTitle.displayName = 'AlertTitle'; const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); AlertDescription.displayName = 'AlertDescription'; export { Alert, AlertTitle, AlertDescription }; ================================================ FILE: packages/ui/src/components/AppBar.tsx ================================================ import { DragDropContext, Draggable, Droppable, type DropResult, } from '@hello-pangea/dnd'; import type { ReactNode } from 'react'; import { LayoutIcon, LinkIcon, PlusIcon, KanbanIcon, SpinnerIcon, StarIcon, } from '@phosphor-icons/react'; import { cn } from '../lib/cn'; import { AppBarButton } from './AppBarButton'; import { AppBarSocialLink } from './AppBarSocialLink'; import { Popover, PopoverTrigger, PopoverContent, PopoverClose, } from './Popover'; import { Tooltip } from './Tooltip'; import { useTranslation } from 'react-i18next'; function formatStarCount(count: number): string { if (count < 1000) return String(count); const k = count / 1000; return k >= 10 ? `${Math.floor(k)}k` : `${k.toFixed(1)}k`; } function getProjectInitials(name: string): string { const trimmed = name.trim(); if (!trimmed) return '??'; const words = trimmed.split(/\s+/); if (words.length >= 2) { return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase(); } return trimmed.slice(0, 2).toUpperCase(); } interface AppBarProps { projects: AppBarProject[]; hosts?: AppBarHost[]; hostsLabel?: string; projectsLabel?: string; onPairHostClick?: () => void; activeHostId?: string | null; onCreateProject: () => void; onWorkspacesClick: () => void; onHostClick?: (hostId: string, status: AppBarHostStatus) => void; showWorkspacesButton?: boolean; onProjectClick: (projectId: string) => void; onProjectsDragEnd: (result: DropResult) => void; isSavingProjectOrder?: boolean; isWorkspacesActive: boolean; activeProjectId: string | null; isSignedIn?: boolean; isLoadingProjects?: boolean; onSignIn?: () => void; onMigrate?: () => void; onHoverStart?: () => void; onHoverEnd?: () => void; notificationBell?: ReactNode; userPopover?: ReactNode; starCount?: number | null; onlineCount?: number | null; appVersion?: string | null; updateVersion?: string | null; onUpdateClick?: () => void; githubIconPath: string; discordIconPath: string; } export interface AppBarProject { id: string; name: string; color: string; } export type AppBarHostStatus = 'online' | 'offline' | 'unpaired'; export interface AppBarHost { id: string; name: string; status: AppBarHostStatus; } function getHostStatusLabel(status: AppBarHostStatus): string { if (status === 'online') return 'Online'; if (status === 'offline') return 'Offline'; return 'Unpaired'; } function getHostStatusIndicatorClass(status: AppBarHostStatus): string { if (status === 'online') return 'bg-success'; if (status === 'offline') return 'bg-low'; return 'bg-white border-warning'; } export function AppBar({ projects, hosts = [], hostsLabel, projectsLabel, onPairHostClick, activeHostId = null, onCreateProject, onWorkspacesClick, onHostClick, showWorkspacesButton = true, onProjectClick, onProjectsDragEnd, isSavingProjectOrder, isWorkspacesActive, activeProjectId, isSignedIn, isLoadingProjects, onSignIn, onMigrate, onHoverStart, onHoverEnd, notificationBell, userPopover, starCount, onlineCount, appVersion, updateVersion, onUpdateClick, githubIconPath, discordIconPath, }: AppBarProps) { const { t } = useTranslation('common'); const showHostsSection = showWorkspacesButton || hosts.length > 0 || !!onPairHostClick; return (
{showHostsSection && (
{hostsLabel && (

{hostsLabel}

)} {showWorkspacesButton && ( )} {hosts.map((host) => { const isOffline = host.status === 'offline'; const isActiveHost = host.id === activeHostId; return (
); })} {onPairHostClick && ( )}
)} {(hosts.length > 0 || onPairHostClick) && ( ); } ================================================ FILE: packages/ui/src/components/AppBarButton.tsx ================================================ import * as React from 'react'; import type { Icon } from '@phosphor-icons/react'; import { cn } from '../lib/cn'; import { Tooltip } from './Tooltip'; interface AppBarButtonProps { icon?: Icon; label: string; isActive?: boolean; onClick?: () => void; className?: string; children?: React.ReactNode; } export function AppBarButton({ icon: IconComponent, label, isActive = false, onClick, className, children, }: AppBarButtonProps) { const button = ( ); return ( {button} ); } ================================================ FILE: packages/ui/src/components/AppBarSocialLink.tsx ================================================ import type { ReactNode } from 'react'; import { cn } from '../lib/cn'; import { Tooltip } from './Tooltip'; interface AppBarSocialLinkProps { href: string; label: string; iconPath: string; badge?: ReactNode; } export function AppBarSocialLink({ href, label, iconPath, badge, }: AppBarSocialLinkProps) { return ( {badge != null && badge !== false && ( {badge} )} ); } ================================================ FILE: packages/ui/src/components/AppBarUserPopover.tsx ================================================ import { BuildingsIcon, CheckIcon, GearIcon, PlusIcon, SignInIcon, SignOutIcon, UserIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { cn } from '../lib/cn'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, } from './Dropdown'; export interface AppBarUserOrganization { id: string; name: string; } interface AppBarUserPopoverProps { isSignedIn: boolean; avatarUrl: string | null; avatarError: boolean; organizations: AppBarUserOrganization[]; selectedOrgId: string; open: boolean; onOpenChange: (open: boolean) => void; onOrgSelect: (orgId: string) => void; onCreateOrg?: () => void; onOrgSettings?: (orgId: string) => void; onSettings?: () => void; onSignIn: () => void; onLogout: () => void; onAvatarError: () => void; } export function AppBarUserPopover({ isSignedIn, avatarUrl, avatarError, organizations, selectedOrgId, open, onOpenChange, onOrgSelect, onCreateOrg, onOrgSettings, onSettings, onSignIn, onLogout, onAvatarError, }: AppBarUserPopoverProps) { const { t } = useTranslation(); const settingsLabel = t('settings:settings.layout.nav.title', { defaultValue: 'Settings', }); if (!isSignedIn) { return ( {t('signIn')} {onSettings && ( <> {settingsLabel} )} ); } return ( {t('orgSwitcher.organizations')} {organizations.map((org) => ( onOrgSelect(org.id)} className={cn(org.id === selectedOrgId && 'bg-brand/10', 'group')} > {org.name} {onOrgSettings && ( )} ))} {t('orgSwitcher.createOrganization')} {onSettings && ( <> {settingsLabel} )} {t('signOut')} ); } ================================================ FILE: packages/ui/src/components/AskUserQuestionBanner.tsx ================================================ import { forwardRef, useCallback, useImperativeHandle, useMemo, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import type { AskUserQuestionItem, QuestionAnswer } from 'shared/types'; import { QuestionIcon } from '@phosphor-icons/react'; export interface AskUserQuestionBannerHandle { /** Submit a custom free-text answer for the current question (triggered by Enter in the editor) */ submitCustomAnswer: (text: string) => void; } interface AskUserQuestionBannerProps { questions: AskUserQuestionItem[]; onSubmitAnswers: (answers: QuestionAnswer[]) => void; isSubmitting: boolean; isTimedOut: boolean; error: string | null; } export const AskUserQuestionBanner = forwardRef< AskUserQuestionBannerHandle, AskUserQuestionBannerProps >(function AskUserQuestionBanner( { questions, onSubmitAnswers, isSubmitting, isTimedOut, error }, ref ) { const { t } = useTranslation('common'); // Track completed answers: question text -> selected labels array const [answers, setAnswers] = useState>({}); // Convert internal state to ordered QuestionAnswer[] for submission const toQuestionAnswers = useCallback( (rec: Record): QuestionAnswer[] => questions .filter((q) => rec[q.question] !== undefined) .map((q) => ({ question: q.question, answer: rec[q.question] })), [questions] ); // Track which question index we're currently showing const currentIndex = useMemo(() => { for (let i = 0; i < questions.length; i++) { if (answers[questions[i].question] === undefined) return i; } return questions.length; // all answered }, [questions, answers]); // For multi-select: track toggled labels for the current question const [multiSelectLabels, setMultiSelectLabels] = useState>( new Set() ); const currentQuestion = currentIndex < questions.length ? questions[currentIndex] : null; const isAllAnswered = currentIndex >= questions.length; const disabled = isSubmitting || isTimedOut; // Select an option for single-select questions → immediately advance const handleSelectOption = useCallback( (label: string) => { if (disabled || !currentQuestion) return; if (currentQuestion.multiSelect) { // Toggle label in multi-select set setMultiSelectLabels((prev) => { const next = new Set(prev); if (next.has(label)) { next.delete(label); } else { next.add(label); } return next; }); } else { // Single select → record answer and advance const newAnswers = { ...answers, [currentQuestion.question]: [label], }; setAnswers(newAnswers); // If this was the last question, submit if (currentIndex === questions.length - 1) { onSubmitAnswers(toQuestionAnswers(newAnswers)); } } }, [ disabled, currentQuestion, answers, currentIndex, questions.length, onSubmitAnswers, toQuestionAnswers, ] ); // Confirm multi-select or "Other" text answer const handleConfirmMultiSelect = useCallback(() => { if (disabled || !currentQuestion) return; const labels = Array.from(multiSelectLabels); if (labels.length === 0) return; const newAnswers = { ...answers, [currentQuestion.question]: labels, }; setAnswers(newAnswers); setMultiSelectLabels(new Set()); if (currentIndex === questions.length - 1) { onSubmitAnswers(toQuestionAnswers(newAnswers)); } }, [ disabled, currentQuestion, multiSelectLabels, answers, currentIndex, questions.length, onSubmitAnswers, toQuestionAnswers, ]); useImperativeHandle( ref, () => ({ submitCustomAnswer: (text: string) => { if (disabled || !currentQuestion || !text.trim()) return; const newAnswers = { ...answers, [currentQuestion.question]: [text.trim()], }; setAnswers(newAnswers); if (currentIndex === questions.length - 1) { onSubmitAnswers(toQuestionAnswers(newAnswers)); } }, }), [ disabled, currentQuestion, answers, currentIndex, questions.length, onSubmitAnswers, toQuestionAnswers, ] ); if (isAllAnswered && !isSubmitting) return null; return (
{/* Header */}
{t('askQuestion.title')} {questions.length > 1 && ( ({Math.min(currentIndex + 1, questions.length)}/{questions.length} ) )}
{/* Current question */} {currentQuestion && (
{currentQuestion.header} {currentQuestion.multiSelect && ( {t('askQuestion.selectMultiple')} )}

{currentQuestion.question}

{currentQuestion.options.map((opt) => { const isSelected = currentQuestion.multiSelect && multiSelectLabels.has(opt.label); return ( ); })}
{/* Multi-select confirm button */} {currentQuestion.multiSelect && multiSelectLabels.size > 0 && ( )}
)} {error && (
{error}
)} {isSubmitting && (
{t('askQuestion.submitting')}
)}
); }); ================================================ FILE: packages/ui/src/components/AutoExpandingTextarea.tsx ================================================ import * as React from 'react'; import { cn } from '../lib/cn'; interface AutoExpandingTextareaProps extends React.ComponentProps<'textarea'> { maxRows?: number; disableInternalScroll?: boolean; } const AutoExpandingTextarea = React.forwardRef< HTMLTextAreaElement, AutoExpandingTextareaProps >( ( { className, maxRows = 10, disableInternalScroll = false, ...props }, ref ) => { const internalRef = React.useRef(null); // Get the actual ref to use const textareaRef = ref || internalRef; const adjustHeight = React.useCallback(() => { const textarea = (textareaRef as React.RefObject) .current; if (!textarea) return; // Reset height to auto to get the natural height textarea.style.height = 'auto'; if (disableInternalScroll) { // When parent handles scroll, expand to full content height textarea.style.height = `${textarea.scrollHeight}px`; } else { // Calculate line height const style = window.getComputedStyle(textarea); const lineHeight = parseInt(style.lineHeight) || 20; const paddingTop = parseInt(style.paddingTop) || 0; const paddingBottom = parseInt(style.paddingBottom) || 0; // Calculate max height based on maxRows const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom; // Set the height to scrollHeight, but cap at maxHeight const newHeight = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = `${newHeight}px`; } }, [maxRows, disableInternalScroll, textareaRef]); // Adjust height on mount and when content changes React.useEffect(() => { adjustHeight(); }, [adjustHeight, props.value]); // Adjust height on input const { onInput } = props; const handleInput = React.useCallback( (e: React.FormEvent) => { adjustHeight(); if (onInput) { onInput(e); } }, [adjustHeight, onInput] ); return (
Stored in localStorage under relay_test_access_token. No auto-refresh in this client.
Result
{}
Log
Ready.
================================================ FILE: scripts/ring-cc-wrapper.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Uses clang for ring (clang-cl rejects ring's build) and clang-cl elsewhere. # See https://github.com/briansmith/ring/issues/2117 ring_cc="${RING_CC:-clang}" default_cc="${DEFAULT_CC:-clang-cl}" if [[ "${CARGO_PKG_NAME:-}" != "ring" && "${CARGO_MANIFEST_DIR:-}" != *"/ring-"* ]]; then exec "$default_cc" "$@" fi args=() while (( $# )); do arg="$1" shift case "$arg" in /imsvc) [[ $# -gt 0 ]] && { args+=(-isystem "$1"); shift; } || args+=("$arg") ;; /imsvc*) args+=(-isystem "${arg#/imsvc}") ;; /I) [[ $# -gt 0 ]] && { args+=(-I "$1"); shift; } || args+=("$arg") ;; /I*) args+=(-I "${arg#/I}") ;; *) args+=("$arg") ;; esac done exec "$ring_cc" "${args[@]}" ================================================ FILE: scripts/setup-dev-environment.js ================================================ #!/usr/bin/env node const fs = require("fs"); const path = require("path"); const net = require("net"); const PORTS_FILE = path.join(__dirname, "..", ".dev-ports.json"); const DEV_ASSETS_SEED = path.join(__dirname, "..", "dev_assets_seed"); const DEV_ASSETS = path.join(__dirname, "..", "dev_assets"); /** * Check if a port is available */ function isPortAvailable(port) { return new Promise((resolve) => { const sock = net.createConnection({ port, host: "localhost" }); sock.on("connect", () => { sock.destroy(); resolve(false); }); sock.on("error", () => resolve(true)); }); } /** * Find a free port starting from a given port */ async function findFreePort(startPort = 3000) { let port = startPort; while (!(await isPortAvailable(port))) { port++; if (port > 65535) { throw new Error("No available ports found"); } } return port; } /** * Load existing ports from file */ function loadPorts() { try { if (fs.existsSync(PORTS_FILE)) { const data = fs.readFileSync(PORTS_FILE, "utf8"); return JSON.parse(data); } } catch (error) { console.warn("Failed to load existing ports:", error.message); } return null; } /** * Save ports to file */ function savePorts(ports) { try { fs.writeFileSync(PORTS_FILE, JSON.stringify(ports, null, 2)); } catch (error) { console.error("Failed to save ports:", error.message); throw error; } } /** * Verify that saved ports are still available */ async function verifyPorts(ports) { const frontendAvailable = await isPortAvailable(ports.frontend); const backendAvailable = await isPortAvailable(ports.backend); const previewProxyAvailable = await isPortAvailable(ports.preview_proxy); if (process.argv[2] === "get" && (!frontendAvailable || !backendAvailable || !previewProxyAvailable)) { console.log( `Port availability check failed: frontend:${ports.frontend}=${frontendAvailable}, backend:${ports.backend}=${backendAvailable}, preview_proxy:${ports.preview_proxy}=${previewProxyAvailable}` ); } return frontendAvailable && backendAvailable && previewProxyAvailable; } /** * Allocate ports for development */ async function allocatePorts() { // If PORT env is set, use it for frontend and PORT+1 for backend if (process.env.PORT) { const frontendPort = parseInt(process.env.PORT, 10); const backendPort = frontendPort + 1; const previewProxyPort = backendPort + 1; const ports = { frontend: frontendPort, backend: backendPort, preview_proxy: previewProxyPort, timestamp: new Date().toISOString(), }; if (process.argv[2] === "get") { console.log("Using PORT environment variable:"); console.log(`Frontend: ${ports.frontend}`); console.log(`Backend: ${ports.backend}`); console.log(`Preview Proxy: ${ports.preview_proxy}`); } return ports; } // Try to load existing ports first const existingPorts = loadPorts(); if (existingPorts) { // Verify existing ports are still available if (await verifyPorts(existingPorts)) { if (process.argv[2] === "get") { console.log("Reusing existing dev ports:"); console.log(`Frontend: ${existingPorts.frontend}`); console.log(`Backend: ${existingPorts.backend}`); console.log(`Preview Proxy: ${existingPorts.preview_proxy}`); } return existingPorts; } else { if (process.argv[2] === "get") { console.log( "Existing ports are no longer available, finding new ones..." ); } } } // Find new free ports const frontendPort = await findFreePort(3000); const backendPort = await findFreePort(frontendPort + 1); const previewProxyPort = await findFreePort(backendPort + 1); const ports = { frontend: frontendPort, backend: backendPort, preview_proxy: previewProxyPort, timestamp: new Date().toISOString(), }; savePorts(ports); if (process.argv[2] === "get") { console.log("Allocated new dev ports:"); console.log(`Frontend: ${ports.frontend}`); console.log(`Backend: ${ports.backend}`); console.log(`Preview Proxy: ${ports.preview_proxy}`); } return ports; } /** * Get ports (allocate if needed) */ async function getPorts() { const ports = await allocatePorts(); copyDevAssets(); return ports; } /** * Copy dev_assets_seed to dev_assets */ function copyDevAssets() { try { if (!fs.existsSync(DEV_ASSETS)) { // Copy dev_assets_seed to dev_assets fs.cpSync(DEV_ASSETS_SEED, DEV_ASSETS, { recursive: true }); if (process.argv[2] === "get") { console.log("Copied dev_assets_seed to dev_assets"); } } } catch (error) { console.error("Failed to copy dev assets:", error.message); } } /** * Clear saved ports */ function clearPorts() { try { if (fs.existsSync(PORTS_FILE)) { fs.unlinkSync(PORTS_FILE); console.log("Cleared saved dev ports"); } else { console.log("No saved ports to clear"); } } catch (error) { console.error("Failed to clear ports:", error.message); } } // CLI interface if (require.main === module) { const command = process.argv[2]; switch (command) { case "get": getPorts() .then((ports) => { console.log(JSON.stringify(ports)); }) .catch(console.error); break; case "clear": clearPorts(); break; case "frontend": getPorts() .then((ports) => { console.log(JSON.stringify(ports.frontend, null, 2)); }) .catch(console.error); break; case "backend": getPorts() .then((ports) => { console.log(JSON.stringify(ports.backend, null, 2)); }) .catch(console.error); break; case "preview_proxy": getPorts() .then((ports) => { console.log(JSON.stringify(ports.preview_proxy, null, 2)); }) .catch(console.error); break; default: console.log("Usage:"); console.log( " node setup-dev-environment.js get - Setup dev environment (ports + assets)" ); console.log( " node setup-dev-environment.js frontend - Get frontend port only" ); console.log( " node setup-dev-environment.js backend - Get backend port only" ); console.log( " node setup-dev-environment.js preview_proxy - Get preview proxy port only" ); console.log( " node setup-dev-environment.js clear - Clear saved ports" ); break; } } module.exports = { getPorts, clearPorts, findFreePort }; ================================================ FILE: shared/jwt.ts ================================================ import { jwtDecode } from 'jwt-decode'; type AccessTokenClaims = { exp: number; aud: string; }; const TOKEN_REFRESH_LEEWAY_MS = 20_000; const ACCESS_TOKEN_AUD = 'access'; const getTokenExpiryMs = (token: string): number | null => { try { const { exp, aud } = jwtDecode(token); if (aud !== ACCESS_TOKEN_AUD) return null; if (!Number.isFinite(exp)) return null; return exp * 1000; } catch { return null; } }; export const shouldRefreshAccessToken = (token: string): boolean => { const expiresAt = getTokenExpiryMs(token); if (expiresAt === null) return true; return expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS; }; ================================================ FILE: shared/remote-types.ts ================================================ // This file was auto-generated by generate_types in the remote crate. // Do not edit manually. // Electric row types export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; export type Project = { id: string, organization_id: string, name: string, color: string, sort_order: number, created_at: string, updated_at: string, }; export type Notification = { id: string, organization_id: string, user_id: string, notification_type: NotificationType, payload: NotificationPayload, issue_id: string | null, comment_id: string | null, seen: boolean, dismissed_at: string | null, created_at: string, }; export type NotificationGroupKind = "single" | "issue_changes" | "status_changes" | "comments" | "reactions" | "issue_deleted"; export type NotificationPayload = { deeplink_path?: string | null, issue_id?: string | null, issue_simple_id?: string | null, issue_title?: string | null, actor_user_id?: string | null, comment_preview?: string | null, old_status_id?: string | null, new_status_id?: string | null, old_status_name?: string | null, new_status_name?: string | null, new_title?: string | null, old_priority?: IssuePriority | null, new_priority?: IssuePriority | null, assignee_user_id?: string | null, emoji?: string | null, }; export type NotificationType = "issue_comment_added" | "issue_status_changed" | "issue_assignee_changed" | "issue_priority_changed" | "issue_unassigned" | "issue_comment_reaction" | "issue_deleted" | "issue_title_changed" | "issue_description_changed"; export type Workspace = { id: string, project_id: string, owner_user_id: string, issue_id: string | null, local_workspace_id: string | null, name: string | null, archived: boolean, files_changed: number | null, lines_added: number | null, lines_removed: number | null, created_at: string, updated_at: string, }; export type ProjectStatus = { id: string, project_id: string, name: string, color: string, sort_order: number, hidden: boolean, created_at: string, }; export type Tag = { id: string, project_id: string, name: string, color: string, }; export type Issue = { id: string, project_id: string, issue_number: number, simple_id: string, status_id: string, title: string, description: string | null, priority: IssuePriority | null, start_date: string | null, target_date: string | null, completed_at: string | null, sort_order: number, parent_issue_id: string | null, parent_issue_sort_order: number | null, extension_metadata: JsonValue, creator_user_id: string | null, created_at: string, updated_at: string, }; export type IssueAssignee = { id: string, issue_id: string, user_id: string, assigned_at: string, }; export type Blob = { id: string, project_id: string, blob_path: string, thumbnail_blob_path: string | null, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, width: number | null, height: number | null, created_at: string, updated_at: string, }; export type Attachment = { id: string, blob_id: string, issue_id: string | null, comment_id: string | null, created_at: string, expires_at: string | null, }; export type AttachmentWithBlob = { id: string, blob_id: string, issue_id: string | null, comment_id: string | null, created_at: string, expires_at: string | null, blob_path: string, thumbnail_blob_path: string | null, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, width: number | null, height: number | null, }; export type IssueFollower = { id: string, issue_id: string, user_id: string, }; export type IssueTag = { id: string, issue_id: string, tag_id: string, }; export type IssueRelationship = { id: string, issue_id: string, related_issue_id: string, relationship_type: IssueRelationshipType, created_at: string, }; export type IssueRelationshipType = "blocking" | "related" | "has_duplicate"; export type IssueComment = { id: string, issue_id: string, author_id: string | null, parent_id: string | null, message: string, created_at: string, updated_at: string, }; export type IssueCommentReaction = { id: string, comment_id: string, user_id: string, emoji: string, created_at: string, }; export type IssuePriority = "urgent" | "high" | "medium" | "low"; export type IssueSortField = "sort_order" | "priority" | "created_at" | "updated_at" | "title"; export type ListIssuesQuery = { project_id: string, }; export type SearchIssuesRequest = { project_id: string, status_id?: string, status_ids?: Array, priority?: IssuePriority, parent_issue_id?: string, search?: string, simple_id?: string, assignee_user_id?: string, tag_id?: string, tag_ids?: Array, sort_field?: IssueSortField, sort_direction?: SortDirection, limit?: number, offset?: number, }; export type ListIssuesResponse = { issues: Array, total_count: number, limit: number, offset: number, }; export type PullRequestStatus = "open" | "merged" | "closed"; export type PullRequest = { id: string, url: string, number: number, status: PullRequestStatus, merged_at: string | null, merge_commit_sha: string | null, target_branch_name: string, issue_id: string, workspace_id: string | null, created_at: string, updated_at: string, }; export type SortDirection = "asc" | "desc"; export type UserData = { user_id: string, first_name: string | null, last_name: string | null, username: string | null, }; export type User = { id: string, email: string, first_name: string | null, last_name: string | null, username: string | null, created_at: string, updated_at: string, }; export type RelayHost = { id: string, owner_user_id: string, name: string, status: string, last_seen_at: string | null, agent_version: string | null, created_at: string, updated_at: string, access_role: string, }; export type ListRelayHostsResponse = { hosts: Array, }; export type RelaySession = { id: string, host_id: string, request_user_id: string, state: string, created_at: string, expires_at: string, claimed_at: string | null, ended_at: string | null, }; export type CreateRelaySessionResponse = { session: RelaySession, }; export type RelaySessionAuthCodeResponse = { session_id: string, code: string, }; export enum MemberRole { ADMIN = "ADMIN", MEMBER = "MEMBER" } export type OrganizationMember = { organization_id: string, user_id: string, role: MemberRole, joined_at: string, last_seen_at: string | null, }; export type CreateProjectRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, organization_id: string, name: string, color: string, }; export type UpdateProjectRequest = { name: string | null, color: string | null, sort_order: number | null, }; export type UpdateNotificationRequest = { seen: boolean | null, }; export type CreateTagRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, project_id: string, name: string, color: string, }; export type UpdateTagRequest = { name: string | null, color: string | null, }; export type CreateProjectStatusRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, project_id: string, name: string, color: string, sort_order: number, hidden: boolean, }; export type UpdateProjectStatusRequest = { name: string | null, color: string | null, sort_order: number | null, hidden: boolean | null, }; export type CreateIssueRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, project_id: string, status_id: string, title: string, description: string | null, priority: IssuePriority | null, start_date: string | null, target_date: string | null, completed_at: string | null, sort_order: number, parent_issue_id: string | null, parent_issue_sort_order: number | null, extension_metadata: JsonValue, }; export type UpdateIssueRequest = { status_id?: string | null, title?: string | null, description?: string | null | null, priority?: IssuePriority | null | null, start_date?: string | null | null, target_date?: string | null | null, completed_at?: string | null | null, sort_order?: number | null, parent_issue_id?: string | null | null, parent_issue_sort_order?: number | null | null, extension_metadata?: JsonValue | null, }; export type CreateIssueAssigneeRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, issue_id: string, user_id: string, }; export type CreateIssueFollowerRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, issue_id: string, user_id: string, }; export type CreateIssueTagRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, issue_id: string, tag_id: string, }; export type CreateIssueRelationshipRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, issue_id: string, related_issue_id: string, relationship_type: IssueRelationshipType, }; export type CreateIssueCommentRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, issue_id: string, message: string, parent_id: string | null, }; export type UpdateIssueCommentRequest = { message: string | null, parent_id: string | null | null, }; export type CreateIssueCommentReactionRequest = { /** * Optional client-generated ID. If not provided, server generates one. * Using client-generated IDs enables stable optimistic updates. */ id?: string, comment_id: string, emoji: string, }; export type UpdateIssueCommentReactionRequest = { emoji: string | null, }; export type InitUploadRequest = { project_id: string, filename: string, size_bytes: number, hash: string, }; export type InitUploadResponse = { upload_url: string, upload_id: string, expires_at: string, skip_upload: boolean, existing_blob_id: string | null, }; export type ConfirmUploadRequest = { project_id: string, upload_id: string, filename: string, content_type?: string, size_bytes: number, hash: string, issue_id?: string, comment_id?: string, }; export type CommitAttachmentsRequest = { attachment_ids: Array, }; export type CommitAttachmentsResponse = { attachments: Array, }; export type AttachmentUrlResponse = { url: string, }; // Shape definition interface export interface ShapeDefinition { readonly table: string; readonly params: readonly string[]; readonly url: string; readonly fallbackUrl: string; readonly _type: T; // Phantom field for type inference (not present at runtime) } // Helper to create type-safe shape definitions function defineShape( table: string, params: readonly string[], url: string, fallbackUrl: string ): ShapeDefinition { return { table, params, url, fallbackUrl } as ShapeDefinition; } // Individual shape definitions with embedded types export const PROJECTS_SHAPE = defineShape( 'projects', ['organization_id'] as const, '/v1/shape/projects', '/v1/fallback/projects' ); export const NOTIFICATIONS_SHAPE = defineShape( 'notifications', ['user_id'] as const, '/v1/shape/notifications', '/v1/fallback/notifications' ); export const ORGANIZATION_MEMBERS_SHAPE = defineShape( 'organization_member_metadata', ['organization_id'] as const, '/v1/shape/organization_members', '/v1/fallback/organization_members' ); export const USERS_SHAPE = defineShape( 'users', ['organization_id'] as const, '/v1/shape/users', '/v1/fallback/users' ); export const PROJECT_TAGS_SHAPE = defineShape( 'tags', ['project_id'] as const, '/v1/shape/project/{project_id}/tags', '/v1/fallback/tags' ); export const PROJECT_PROJECT_STATUSES_SHAPE = defineShape( 'project_statuses', ['project_id'] as const, '/v1/shape/project/{project_id}/project_statuses', '/v1/fallback/project_statuses' ); export const PROJECT_ISSUES_SHAPE = defineShape( 'issues', ['project_id'] as const, '/v1/shape/project/{project_id}/issues', '/v1/fallback/issues' ); export const USER_WORKSPACES_SHAPE = defineShape( 'workspaces', ['owner_user_id'] as const, '/v1/shape/user/workspaces', '/v1/fallback/user_workspaces' ); export const PROJECT_WORKSPACES_SHAPE = defineShape( 'workspaces', ['project_id'] as const, '/v1/shape/project/{project_id}/workspaces', '/v1/fallback/project_workspaces' ); export const PROJECT_ISSUE_ASSIGNEES_SHAPE = defineShape( 'issue_assignees', ['project_id'] as const, '/v1/shape/project/{project_id}/issue_assignees', '/v1/fallback/issue_assignees' ); export const PROJECT_ISSUE_FOLLOWERS_SHAPE = defineShape( 'issue_followers', ['project_id'] as const, '/v1/shape/project/{project_id}/issue_followers', '/v1/fallback/issue_followers' ); export const PROJECT_ISSUE_TAGS_SHAPE = defineShape( 'issue_tags', ['project_id'] as const, '/v1/shape/project/{project_id}/issue_tags', '/v1/fallback/issue_tags' ); export const PROJECT_ISSUE_RELATIONSHIPS_SHAPE = defineShape( 'issue_relationships', ['project_id'] as const, '/v1/shape/project/{project_id}/issue_relationships', '/v1/fallback/issue_relationships' ); export const PROJECT_PULL_REQUESTS_SHAPE = defineShape( 'pull_requests', ['project_id'] as const, '/v1/shape/project/{project_id}/pull_requests', '/v1/fallback/pull_requests' ); export const ISSUE_COMMENTS_SHAPE = defineShape( 'issue_comments', ['issue_id'] as const, '/v1/shape/issue/{issue_id}/comments', '/v1/fallback/issue_comments' ); export const ISSUE_REACTIONS_SHAPE = defineShape( 'issue_comment_reactions', ['issue_id'] as const, '/v1/shape/issue/{issue_id}/reactions', '/v1/fallback/issue_comment_reactions' ); // ============================================================================= // Mutation Definitions // ============================================================================= // Mutation definition interface export interface MutationDefinition { readonly name: string; readonly url: string; readonly _rowType: TRow; // Phantom field for type inference (not present at runtime) readonly _createType: TCreate; // Phantom field for type inference (not present at runtime) readonly _updateType: TUpdate; // Phantom field for type inference (not present at runtime) } // Helper to create type-safe mutation definitions function defineMutation( name: string, url: string ): MutationDefinition { return { name, url } as MutationDefinition; } // Individual mutation definitions export const PROJECT_MUTATION = defineMutation( 'Project', '/v1/projects' ); export const NOTIFICATION_MUTATION = defineMutation( 'Notification', '/v1/notifications' ); export const TAG_MUTATION = defineMutation( 'Tag', '/v1/tags' ); export const PROJECT_STATUS_MUTATION = defineMutation( 'ProjectStatus', '/v1/project_statuses' ); export const ISSUE_MUTATION = defineMutation( 'Issue', '/v1/issues' ); export const ISSUE_ASSIGNEE_MUTATION = defineMutation( 'IssueAssignee', '/v1/issue_assignees' ); export const ISSUE_FOLLOWER_MUTATION = defineMutation( 'IssueFollower', '/v1/issue_followers' ); export const ISSUE_TAG_MUTATION = defineMutation( 'IssueTag', '/v1/issue_tags' ); export const ISSUE_RELATIONSHIP_MUTATION = defineMutation( 'IssueRelationship', '/v1/issue_relationships' ); export const ISSUE_COMMENT_MUTATION = defineMutation( 'IssueComment', '/v1/issue_comments' ); export const ISSUE_COMMENT_REACTION_MUTATION = defineMutation( 'IssueCommentReaction', '/v1/issue_comment_reactions' ); // Type helpers to extract types from a mutation definition export type MutationRowType> = M extends MutationDefinition ? R : never; export type MutationCreateType> = M extends MutationDefinition ? C : never; export type MutationUpdateType> = M extends MutationDefinition ? U : never; ================================================ FILE: shared/schemas/amp.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "dangerously_allow_all": { "title": "Dangerously Allow All", "description": "Allow all commands to be executed, even if they are not safe.", "type": [ "boolean", "null" ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/claude_code.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "claude_code_router": { "type": [ "boolean", "null" ] }, "plan": { "type": [ "boolean", "null" ] }, "approvals": { "type": [ "boolean", "null" ] }, "model": { "type": [ "string", "null" ] }, "effort": { "type": [ "string", "null" ], "enum": [ "low", "medium", "high", "max", null ] }, "agent": { "type": [ "string", "null" ] }, "dangerously_skip_permissions": { "type": [ "boolean", "null" ] }, "disable_api_key": { "type": [ "boolean", "null" ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/codex.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "sandbox": { "description": "Sandbox policy modes for Codex", "type": [ "string", "null" ], "enum": [ "auto", "read-only", "workspace-write", "danger-full-access", null ] }, "ask_for_approval": { "description": "Determines when the user is consulted to approve Codex actions.\n\n- `UnlessTrusted`: Read-only commands are auto-approved. Everything else will\n ask the user to approve.\n- `OnFailure`: All commands run in a restricted sandbox initially. If a\n command fails, the user is asked to approve execution without the sandbox.\n- `OnRequest`: The model decides when to ask the user for approval.\n- `Never`: Commands never ask for approval. Commands that fail in the\n restricted sandbox are not retried.", "type": [ "string", "null" ], "enum": [ "unless-trusted", "on-failure", "on-request", "never", null ] }, "oss": { "type": [ "boolean", "null" ] }, "model": { "type": [ "string", "null" ] }, "model_reasoning_effort": { "description": "Reasoning effort for the underlying model", "type": [ "string", "null" ], "enum": [ "low", "medium", "high", "xhigh", null ] }, "model_reasoning_summary": { "description": "Model reasoning summary style", "type": [ "string", "null" ], "enum": [ "auto", "concise", "detailed", "none", null ] }, "model_reasoning_summary_format": { "description": "Format for model reasoning summaries", "type": [ "string", "null" ], "enum": [ "none", "experimental", null ] }, "profile": { "type": [ "string", "null" ] }, "base_instructions": { "type": [ "string", "null" ] }, "include_apply_patch_tool": { "type": [ "boolean", "null" ] }, "model_provider": { "type": [ "string", "null" ] }, "compact_prompt": { "type": [ "string", "null" ] }, "developer_instructions": { "type": [ "string", "null" ] }, "plan": { "type": "boolean", "default": false }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/copilot.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "model": { "type": [ "string", "null" ] }, "allow_all_tools": { "type": [ "boolean", "null" ] }, "allow_tool": { "type": [ "string", "null" ] }, "deny_tool": { "type": [ "string", "null" ] }, "add_dir": { "type": [ "array", "null" ], "items": { "type": "string" } }, "disable_mcp_server": { "type": [ "array", "null" ], "items": { "type": "string" } }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/cursor_agent.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "force": { "description": "Force allow commands unless explicitly denied", "type": [ "boolean", "null" ] }, "model": { "description": "auto, opus-4.6, sonnet-4.6, gpt-5.4, gpt-5.4-fast, gpt-5.3-codex, gpt-5.3-codex-fast, gpt-5.3-codex-spark-preview, gpt-5.2, gpt-5.2-codex, gpt-5.2-codex-fast, gpt-5.1, gpt-5.1-codex-max, gpt-5.1-codex-mini, grok, kimi-k2.5, gemini-3.1-pro, gemini-3-pro, gemini-3-flash, opus-4.5, sonnet-4.5, composer-1.5, composer-1", "type": [ "string", "null" ] }, "reasoning": { "type": [ "string", "null" ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/droid.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "autonomy": { "title": "Autonomy Level", "description": "Permission level for file and system operations", "type": "string", "enum": [ "normal", "low", "medium", "high", "skip-permissions-unsafe" ], "default": "skip-permissions-unsafe" }, "model": { "title": "Model", "description": "Model to use (e.g., gpt-5-codex, claude-sonnet-4-5-20250929, gpt-5-2025-08-07, claude-opus-4-1-20250805, claude-haiku-4-5-20251001, glm-4.6)", "type": [ "string", "null" ] }, "reasoning_effort": { "title": "Reasoning Effort", "description": "Reasoning effort level: none, dynamic, off, low, medium, high", "type": [ "string", "null" ], "enum": [ "none", "dynamic", "off", "low", "medium", "high", null ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "description": "Droid executor configuration", "type": "object" } ================================================ FILE: shared/schemas/gemini.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "model": { "type": [ "string", "null" ] }, "yolo": { "type": [ "boolean", "null" ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/opencode.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "model": { "type": [ "string", "null" ] }, "variant": { "type": [ "string", "null" ] }, "agent": { "type": [ "string", "null" ] }, "auto_approve": { "description": "Auto-approve agent actions", "type": "boolean", "default": true }, "auto_compact": { "description": "Enable auto-compaction when the context length approaches the model's context window limit", "type": "boolean", "default": true }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/schemas/qwen_code.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "append_prompt": { "title": "Append Prompt", "description": "Extra text appended to the prompt", "type": [ "string", "null" ], "format": "textarea", "default": null }, "model": { "type": [ "string", "null" ] }, "agent": { "type": [ "string", "null" ] }, "yolo": { "type": [ "boolean", "null" ] }, "base_command_override": { "title": "Base Command Override", "description": "Override the base command with a custom command", "type": [ "string", "null" ] }, "additional_params": { "title": "Additional Parameters", "description": "Additional parameters to append to the base command", "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "title": "Environment Variables", "description": "Environment variables to set when running the executor", "type": [ "object", "null" ], "additionalProperties": { "type": "string" } } }, "type": "object" } ================================================ FILE: shared/types.ts ================================================ // This file was generated by `crates/core/src/bin/generate_types.rs`. // Do not edit this file manually. // If you are an AI, and you absolutely have to edit this file, please confirm with the user first. export type Repo = { id: string, path: string, name: string, display_name: string, setup_script: string | null, cleanup_script: string | null, archive_script: string | null, copy_files: string | null, parallel_setup_script: boolean, dev_server_script: string | null, default_target_branch: string | null, default_working_dir: string | null, created_at: Date, updated_at: Date, }; export type Project = { id: string, name: string, default_agent_working_dir: string | null, remote_project_id: string | null, created_at: Date, updated_at: Date, }; export type UpdateRepo = { display_name?: string | null, setup_script?: string | null, cleanup_script?: string | null, archive_script?: string | null, copy_files?: string | null, parallel_setup_script?: boolean | null, dev_server_script?: string | null, default_target_branch?: string | null, default_working_dir?: string | null, }; export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, /** * Ranking score based on git history (higher = more recently/frequently edited) */ score: bigint, }; export type SearchMatchType = "FileName" | "DirectoryName" | "FullPath"; export type WorkspaceRepo = { id: string, workspace_id: string, repo_id: string, target_branch: string, created_at: Date, updated_at: Date, }; export type CreateWorkspaceRepo = { repo_id: string, target_branch: string, }; export type RepoWithTargetBranch = { target_branch: string, id: string, path: string, name: string, display_name: string, setup_script: string | null, cleanup_script: string | null, archive_script: string | null, copy_files: string | null, parallel_setup_script: boolean, dev_server_script: string | null, default_target_branch: string | null, default_working_dir: string | null, created_at: Date, updated_at: Date, }; export type Tag = { id: string, tag_name: string, content: string, created_at: string, updated_at: string, }; export type CreateTag = { tag_name: string, content: string, }; export type UpdateTag = { tag_name: string | null, content: string | null, }; export type DraftFollowUpData = { message: string, executor_config: ExecutorConfig, }; export type DraftWorkspaceData = { message: string, repos: Array, executor_config: ExecutorConfig | null, linked_issue: DraftWorkspaceLinkedIssue | null, attachments: Array, }; export type DraftWorkspaceAttachment = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, }; export type DraftWorkspaceLinkedIssue = { issue_id: string, simple_id: string, title: string, remote_project_id: string, }; export type DraftWorkspaceRepo = { repo_id: string, target_branch: string, }; export type DraftIssueData = { title: string, description: string | null, status_id: string, /** * Stored as the string value of IssuePriority (e.g. "urgent", "high", "medium", "low") */ priority: string | null, assignee_ids: Array, tag_ids: Array, create_draft_workspace: boolean, /** * The project this draft belongs to */ project_id: string, /** * Parent issue ID if creating a sub-issue */ parent_issue_id: string | null, }; export type PreviewSettingsData = { url: string, screen_size: string | null, responsive_width: number | null, responsive_height: number | null, }; export type WorkspaceNotesData = { content: string, }; export type WorkspacePanelStateData = { right_main_panel_mode: string | null, is_left_main_panel_visible: boolean, }; export type WorkspacePrFilterData = "all" | "has_pr" | "no_pr"; export type WorkspaceSortByData = "updated_at" | "created_at"; export type WorkspaceSortOrderData = "asc" | "desc"; export type WorkspaceFilterStateData = { project_ids: Array, pr_filter: WorkspacePrFilterData, }; export type WorkspaceSortStateData = { sort_by: WorkspaceSortByData, sort_order: WorkspaceSortOrderData, }; export type UiPreferencesData = { /** * Preferred repo actions per repo */ repo_actions: { [key in string]?: string }, /** * Expanded/collapsed state for UI sections */ expanded: { [key in string]?: boolean }, /** * Context bar position */ context_bar_position: string | null, /** * Pane sizes */ pane_sizes: { [key in string]?: JsonValue }, /** * Collapsed paths per workspace in file tree */ collapsed_paths: { [key in string]?: Array }, /** * Preferred file-search repo */ file_search_repo_id: string | null, /** * Global left sidebar visibility */ is_left_sidebar_visible: boolean | null, /** * Global right sidebar visibility */ is_right_sidebar_visible: boolean | null, /** * Global terminal visibility */ is_terminal_visible: boolean | null, /** * Workspace-specific panel states */ workspace_panel_states: { [key in string]?: WorkspacePanelStateData }, /** * Workspace sidebar filter preferences */ workspace_filters: WorkspaceFilterStateData, /** * Workspace sidebar sort preferences */ workspace_sort: WorkspaceSortStateData, /** * Last selected organization ID */ selected_org_id: string | null, /** * Last selected project ID */ selected_project_id: string | null, /** * Default setting for creating a draft workspace from new issues */ create_draft_workspace_by_default: boolean | null, }; export type ProjectRepoDefaultsData = { repos: Array, }; export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData } | { "type": "DRAFT_ISSUE", "data": DraftIssueData } | { "type": "PREVIEW_SETTINGS", "data": PreviewSettingsData } | { "type": "WORKSPACE_NOTES", "data": WorkspaceNotesData } | { "type": "UI_PREFERENCES", "data": UiPreferencesData } | { "type": "PROJECT_REPO_DEFAULTS", "data": ProjectRepoDefaultsData }; export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", DRAFT_ISSUE = "DRAFT_ISSUE", PREVIEW_SETTINGS = "PREVIEW_SETTINGS", WORKSPACE_NOTES = "WORKSPACE_NOTES", UI_PREFERENCES = "UI_PREFERENCES", PROJECT_REPO_DEFAULTS = "PROJECT_REPO_DEFAULTS" } export type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, }; export type CreateScratch = { payload: ScratchPayload, }; export type UpdateScratch = { payload: ScratchPayload, }; export type Workspace = { id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; export type WorkspaceWithStatus = { is_running: boolean, is_errored: boolean, id: string, task_id: string | null, container_ref: string | null, branch: string, setup_completed_at: string | null, created_at: string, updated_at: string, archived: boolean, pinned: boolean, name: string | null, worktree_deleted: boolean, }; export type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, }; export type ExecutionProcess = { id: string, session_id: string, run_reason: ExecutionProcessRunReason, executor_action: ExecutorAction, status: ExecutionProcessStatus, exit_code: bigint | null, /** * dropped: true if this process is excluded from the current * history view (due to restore/trimming). Hidden from logs/timeline; * still listed in the Processes tab. */ dropped: boolean, started_at: string, completed_at: string | null, created_at: string, updated_at: string, }; export enum ExecutionProcessStatus { running = "running", completed = "completed", failed = "failed", killed = "killed" } export type ExecutionProcessRunReason = "setupscript" | "cleanupscript" | "archivescript" | "codingagent" | "devserver"; export type ExecutionProcessRepoState = { id: string, execution_process_id: string, repo_id: string, before_head_commit: string | null, after_head_commit: string | null, merge_commit: string | null, created_at: Date, updated_at: Date, }; export type Merge = { "type": "direct" } & DirectMerge | { "type": "pr" } & PrMerge; export type DirectMerge = { id: string, workspace_id: string, repo_id: string, merge_commit: string, target_branch_name: string, created_at: string, }; export type PrMerge = { id: string, workspace_id: string, repo_id: string, created_at: string, target_branch_name: string, pr_info: PullRequestInfo, }; export type MergeStatus = "open" | "merged" | "closed" | "unknown"; export type PullRequestInfo = { number: bigint, url: string, status: MergeStatus, merged_at: string | null, merge_commit_sha: string | null, }; export type ApprovalInfo = { approval_id: string, tool_name: string, execution_process_id: string, is_question: boolean, created_at: string, timeout_at: string, }; export type ApprovalStatus = { "status": "pending" } | { "status": "approved" } | { "status": "denied", reason?: string, } | { "status": "timed_out" }; export type QuestionAnswer = { question: string, answer: Array, }; export type QuestionStatus = { "status": "answered", answers: Array, } | { "status": "timed_out" }; export type ApprovalOutcome = { "status": "approved" } | { "status": "denied", reason?: string, } | { "status": "answered", answers: Array, } | { "status": "timed_out" }; export type ApprovalResponse = { execution_process_id: string, status: ApprovalOutcome, }; export type Diff = { change: DiffChangeKind, oldPath: string | null, newPath: string | null, oldContent: string | null, newContent: string | null, /** * True when file contents are intentionally omitted (e.g., too large) */ contentOmitted: boolean, /** * Optional precomputed stats for omitted content */ additions: number | null, deletions: number | null, repoId: string | null, }; export type DiffChangeKind = "added" | "deleted" | "modified" | "renamed" | "copied" | "permissionChange"; export type ApiResponse = { success: boolean, data: T | null, error_data: E | null, message: string | null, }; export type LoginStatus = { "status": "loggedout" } | { "status": "loggedin", profile: ProfileResponse, }; export type ProfileResponse = { user_id: string, username: string | null, email: string, providers: Array, }; export type ProviderProfile = { provider: string, username: string | null, display_name: string | null, email: string | null, avatar_url: string | null, }; export type StatusResponse = { logged_in: boolean, profile: ProfileResponse | null, degraded: boolean | null, }; export enum MemberRole { ADMIN = "ADMIN", MEMBER = "MEMBER" } export enum InvitationStatus { PENDING = "PENDING", ACCEPTED = "ACCEPTED", DECLINED = "DECLINED", EXPIRED = "EXPIRED" } export type Organization = { id: string, name: string, slug: string, is_personal: boolean, issue_prefix: string, created_at: string, updated_at: string, }; export type OrganizationWithRole = { id: string, name: string, slug: string, is_personal: boolean, issue_prefix: string, created_at: string, updated_at: string, user_role: MemberRole, }; export type ListOrganizationsResponse = { organizations: Array, }; export type GetOrganizationResponse = { organization: Organization, user_role: string, }; export type CreateOrganizationRequest = { name: string, slug: string, }; export type CreateOrganizationResponse = { organization: OrganizationWithRole, }; export type UpdateOrganizationRequest = { name: string, }; export type Invitation = { id: string, organization_id: string, invited_by_user_id: string | null, email: string, role: MemberRole, status: InvitationStatus, token: string, created_at: string, expires_at: string, }; export type CreateInvitationRequest = { email: string, role: MemberRole, }; export type CreateInvitationResponse = { invitation: Invitation, }; export type ListInvitationsResponse = { invitations: Array, }; export type GetInvitationResponse = { id: string, organization_slug: string, role: MemberRole, expires_at: string, }; export type AcceptInvitationResponse = { organization_id: string, organization_slug: string, role: MemberRole, }; export type RevokeInvitationRequest = { invitation_id: string, }; export type OrganizationMemberInfo = { user_id: string, role: MemberRole, joined_at: string, }; export type OrganizationMemberWithProfile = { user_id: string, role: MemberRole, joined_at: string, first_name: string | null, last_name: string | null, username: string | null, email: string | null, avatar_url: string | null, }; export type ListMembersResponse = { members: Array, }; export type UpdateMemberRoleRequest = { role: MemberRole, }; export type UpdateMemberRoleResponse = { user_id: string, role: MemberRole, }; export type MigrationRequest = { organization_id: string, /** * List of local project IDs to migrate. */ project_ids: Array, }; export type MigrationResponse = { report: MigrationReport, }; export type MigrationReport = { projects: EntityReport, tasks: EntityReport, pr_merges: EntityReport, workspaces: EntityReport, warnings: Array, }; export type EntityReport = { total: number, migrated: number, failed: number, skipped: number, errors: Array, }; export type EntityError = { local_id: string, error: string, }; export type RegisterRepoRequest = { path: string, display_name: string | null, }; export type InitRepoRequest = { parent_path: string, folder_name: string, }; export type TagSearchParams = { search: string | null, }; export type TokenResponse = { access_token: string, expires_at: string | null, }; export type UserSystemInfo = { version: string, config: Config, analytics_user_id: string, login_status: LoginStatus, environment: Environment, /** * Capabilities supported per executor (e.g., { "CLAUDE_CODE": ["SESSION_FORK"] }) */ capabilities: { [key in string]?: Array }, shared_api_base: string | null, preview_proxy_port: number | null, executors: { [key in BaseCodingAgent]?: ExecutorProfile }, }; export type Environment = { os_type: string, os_version: string, os_architecture: string, bitness: string, }; export type McpServerQuery = { executor: BaseCodingAgent, }; export type UpdateMcpServersBody = { servers: { [key in string]?: JsonValue }, }; export type GetMcpServerResponse = { mcp_config: McpConfig, config_path: string, }; export type CheckEditorAvailabilityQuery = { editor_type: EditorType, }; export type CheckEditorAvailabilityResponse = { available: boolean, }; export type CheckAgentAvailabilityQuery = { executor: BaseCodingAgent, }; export type AgentPresetOptionsQuery = { executor: BaseCodingAgent, variant: string | null, }; export type CurrentUserResponse = { user_id: string, }; export type StartSpake2EnrollmentRequest = { enrollment_code: string, client_message_b64: string, }; export type FinishSpake2EnrollmentRequest = { enrollment_id: string, client_id: string, client_name: string, client_browser: string, client_os: string, client_device: string, public_key_b64: string, client_proof_b64: string, }; export type StartSpake2EnrollmentResponse = { enrollment_id: string, server_message_b64: string, }; export type FinishSpake2EnrollmentResponse = { signing_session_id: string, server_public_key_b64: string, server_proof_b64: string, }; export type RelayPairedClient = { client_id: string, client_name: string, client_browser: string, client_os: string, client_device: string, }; export type ListRelayPairedClientsResponse = { clients: Array, }; export type RemoveRelayPairedClientResponse = { removed: boolean, }; export type RefreshRelaySigningSessionRequest = { client_id: string, timestamp: bigint, nonce: string, signature_b64: string, }; export type RefreshRelaySigningSessionResponse = { signing_session_id: string, }; export type CreateFollowUpAttempt = { prompt: string, executor_config: ExecutorConfig, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; export type ResetProcessRequest = { process_id: string, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; export type ChangeTargetBranchRequest = { repo_id: string, new_target_branch: string, }; export type ChangeTargetBranchResponse = { repo_id: string, new_target_branch: string, status: [number, number], }; export type AddWorkspaceRepoRequest = { repo_id: string, target_branch: string, }; export type AddWorkspaceRepoResponse = { workspace: Workspace, repo: RepoWithTargetBranch, }; export type MergeWorkspaceRequest = { repo_id: string, }; export type PushWorkspaceRequest = { repo_id: string, }; export type RenameBranchRequest = { new_branch_name: string, }; export type RenameBranchResponse = { branch: string, }; export type StartReviewRequest = { executor_config: ExecutorConfig, additional_prompt: string | null, use_all_workspace_commits: boolean, }; export type ReviewError = { "type": "process_already_running" }; export type OpenEditorRequest = { editor_type: string | null, file_path: string | null, }; export type OpenEditorResponse = { url: string | null, }; export type LinkedIssueInfo = { remote_project_id: string, issue_id: string, }; export type CreatePrApiRequest = { title: string, body: string | null, target_branch: string | null, draft: boolean | null, repo_id: string, auto_generate_description: boolean, }; export type AttachmentResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; export type AttachmentMetadata = { exists: boolean, file_name: string | null, path: string | null, size_bytes: bigint | null, format: string | null, proxy_url: string | null, }; export type WorkspaceRepoInput = { repo_id: string, target_branch: string, }; export type RunAgentSetupRequest = { executor_profile_id: ExecutorProfileId, }; export type RunAgentSetupResponse = Record; export type GhCliSetupError = "BREW_MISSING" | "SETUP_HELPER_NOT_SUPPORTED" | { "OTHER": { message: string, } }; export type RebaseWorkspaceRequest = { repo_id: string, old_base_branch: string | null, new_base_branch: string | null, }; export type ContinueRebaseRequest = { repo_id: string, }; export type AbortConflictsRequest = { repo_id: string, }; export type GitOperationError = { "type": "merge_conflicts", message: string, op: ConflictOp, conflicted_files: Array, target_branch: string, } | { "type": "rebase_in_progress" }; export type PushError = { "type": "force_push_required" }; export type PrError = { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "cli_not_logged_in", provider: ProviderKind, } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "target_branch_not_found", branch: string, } | { "type": "unsupported_provider" }; export type RunScriptError = { "type": "no_script_configured" } | { "type": "process_already_running" }; export type AssociateWorkspaceAttachmentsRequest = { attachment_ids: Array, }; export type ImportIssueAttachmentsRequest = { issue_id: string, }; export type ImportIssueAttachmentsResponse = { attachment_ids: Array, }; export type AttachPrResponse = { pr_attached: boolean, pr_url: string | null, pr_number: bigint | null, pr_status: MergeStatus | null, }; export type AttachExistingPrRequest = { repo_id: string, }; export type PrCommentsResponse = { comments: Array, }; export type GetPrCommentsError = { "type": "no_pr_attached" } | { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "cli_not_logged_in", provider: ProviderKind, }; export type GetPrCommentsQuery = { repo_id: string, }; export type CreateAndStartWorkspaceRequest = { name: string | null, repos: Array, linked_issue: LinkedIssueInfo | null, executor_config: ExecutorConfig, prompt: string, attachment_ids: Array | null, }; export type CreateAndStartWorkspaceResponse = { workspace: Workspace, execution_process: ExecutionProcess, }; export type UnifiedPrComment = { "comment_type": "general", id: string, author: string, author_association: string | null, body: string, created_at: string, url: string | null, } | { "comment_type": "review", id: bigint, author: string, author_association: string | null, body: string, created_at: string, url: string | null, path: string, line: bigint | null, side: string | null, diff_hunk: string | null, }; export type ProviderKind = "git_hub" | "azure_dev_ops" | "unknown"; export type OpenPrInfo = { number: bigint, url: string, title: string, head_branch: string, base_branch: string, }; export type GitRemote = { name: string, url: string, }; export type ListPrsError = { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "auth_failed", message: string, } | { "type": "unsupported_provider" }; export type CreateWorkspaceFromPrBody = { repo_id: string, pr_number: bigint, pr_title: string, pr_url: string, head_branch: string, base_branch: string, run_setup: boolean, remote_name: string | null, }; export type CreateWorkspaceFromPrResponse = { workspace: Workspace, }; export type CreateFromPrError = { "type": "pr_not_found" } | { "type": "branch_fetch_failed", message: string, } | { "type": "cli_not_installed", provider: ProviderKind, } | { "type": "auth_failed", message: string, } | { "type": "unsupported_provider" }; export type RepoBranchStatus = { repo_id: string, repo_name: string, commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, is_rebase_in_progress: boolean, conflict_op: ConflictOp | null, conflicted_files: Array, is_target_remote: boolean, }; export type UpdateWorkspace = { archived: boolean | null, pinned: boolean | null, name: string | null, }; export type UpdateSession = { name: string | null, }; export type WorkspaceSummaryRequest = { archived: boolean, }; export type WorkspaceSummary = { workspace_id: string, /** * Session ID of the latest execution process */ latest_session_id: string | null, /** * Is a tool approval currently pending? */ has_pending_approval: boolean, /** * Number of files with changes */ files_changed: number | null, /** * Total lines added across all files */ lines_added: number | null, /** * Total lines removed across all files */ lines_removed: number | null, /** * When the latest execution process completed */ latest_process_completed_at?: string, /** * Status of the latest execution process */ latest_process_status: ExecutionProcessStatus | null, /** * Is a dev server currently running? */ has_running_dev_server: boolean, /** * Does this workspace have unseen coding agent turns? */ has_unseen_turns: boolean, /** * PR status for this workspace (if any PR exists) */ pr_status: MergeStatus | null, /** * PR number for this workspace (if any PR exists) */ pr_number: bigint | null, /** * PR URL for this workspace (if any PR exists) */ pr_url: string | null, }; export type WorkspaceSummaryResponse = { summaries: Array, }; export type DiffStats = { files_changed: number, lines_added: number, lines_removed: number, }; export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, last_modified: bigint | null, }; export type DirectoryListResponse = { entries: Array, current_path: string, }; export type SearchMode = "taskform" | "settings"; export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, remote_onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, pr_auto_description_enabled: boolean, pr_auto_description_prompt: string | null, commit_reminder_enabled: boolean, commit_reminder_prompt: string | null, send_message_shortcut: SendMessageShortcut, relay_enabled: boolean, relay_host_name: string | null, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, }; export enum ThemeMode { LIGHT = "LIGHT", DARK = "DARK", SYSTEM = "SYSTEM" } export type EditorConfig = { editor_type: EditorType, custom_command: string | null, remote_ssh_host: string | null, remote_ssh_user: string | null, auto_install_extension: boolean, }; export enum EditorType { VS_CODE = "VS_CODE", VS_CODE_INSIDERS = "VS_CODE_INSIDERS", CURSOR = "CURSOR", WINDSURF = "WINDSURF", INTELLI_J = "INTELLI_J", ZED = "ZED", XCODE = "XCODE", GOOGLE_ANTIGRAVITY = "GOOGLE_ANTIGRAVITY", CUSTOM = "CUSTOM" } export type EditorOpenError = { "type": "executable_not_found", executable: string, editor_type: EditorType, } | { "type": "invalid_command", details: string, editor_type: EditorType, } | { "type": "launch_failed", executable: string, details: string, editor_type: EditorType, }; export type GitHubConfig = { pat: string | null, oauth_token: string | null, username: string | null, primary_email: string | null, default_pr_base: string | null, }; export enum SoundFile { ABSTRACT_SOUND1 = "ABSTRACT_SOUND1", ABSTRACT_SOUND2 = "ABSTRACT_SOUND2", ABSTRACT_SOUND3 = "ABSTRACT_SOUND3", ABSTRACT_SOUND4 = "ABSTRACT_SOUND4", COW_MOOING = "COW_MOOING", FAHHHHH = "FAHHHHH", PHONE_VIBRATION = "PHONE_VIBRATION", ROOSTER = "ROOSTER" } export type UiLanguage = "BROWSER" | "EN" | "FR" | "JA" | "ES" | "KO" | "ZH_HANS" | "ZH_HANT"; export type ShowcaseState = { seen_features: Array, }; export type SendMessageShortcut = "ModifierEnter" | "Enter"; export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, last_commit_date: Date, }; export type QueuedMessage = { /** * The session this message is queued for */ session_id: string, /** * The follow-up data (message + variant) */ data: DraftFollowUpData, /** * Timestamp when the message was queued */ queued_at: string, }; export type QueueStatus = { "status": "empty" } | { "status": "queued", message: QueuedMessage, }; export type ConflictOp = "rebase" | "merge" | "cherry_pick" | "revert"; export type ExecutorAction = { typ: ExecutorActionType, next_action: ExecutorAction | null, }; export type McpConfig = { servers: { [key in string]?: JsonValue }, servers_path: Array, template: JsonValue, preconfigured: JsonValue, is_toml_config: boolean, }; export type ExecutorActionType = { "type": "CodingAgentInitialRequest" } & CodingAgentInitialRequest | { "type": "CodingAgentFollowUpRequest" } & CodingAgentFollowUpRequest | { "type": "ScriptRequest" } & ScriptRequest | { "type": "ReviewRequest" } & ReviewRequest; export type ExecutorConfig = { /** * The executor type (e.g., CLAUDE_CODE, AMP) */ executor: BaseCodingAgent, /** * Optional variant/preset name (e.g., "PLAN", "ROUTER") */ variant?: string | null, /** * Model override (e.g., "anthropic/claude-sonnet-4-20250514") */ model_id?: string | null, /** * Agent mode override */ agent_id?: string | null, /** * Reasoning effort override (e.g., "high", "medium") */ reasoning_id?: string | null, /** * Permission policy override */ permission_policy?: PermissionPolicy | null, }; export type ScriptContext = "SetupScript" | "CleanupScript" | "ArchiveScript" | "DevServer" | "ToolInstallScript"; export type ScriptRequest = { script: string, language: ScriptRequestLanguage, context: ScriptContext, /** * Optional relative path to execute the script in (relative to container_ref). * If None, uses the container_ref directory directly. */ working_dir: string | null, }; export type ScriptRequestLanguage = "Bash"; export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE", CURSOR_AGENT = "CURSOR_AGENT", QWEN_CODE = "QWEN_CODE", COPILOT = "COPILOT", DROID = "DROID" } export type CodingAgent = { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR_AGENT": CursorAgent } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot } | { "DROID": Droid }; export type SlashCommandDescription = { /** * Command name without the leading slash, e.g. `help` for `/help`. */ name: string, description?: string | null, }; export type AvailabilityInfo = { "type": "LOGIN_DETECTED", last_auth_timestamp: bigint, } | { "type": "INSTALLATION_FOUND" } | { "type": "NOT_FOUND" }; export type CommandBuilder = { /** * Base executable command (e.g., "npx -y @anthropic-ai/claude-code@latest") */ base: string, /** * Optional parameters to append to the base command */ params: Array | null, }; export type ExecutorProfileId = { /** * The executor type (e.g., "CLAUDE_CODE", "AMP") */ executor: BaseCodingAgent, /** * Optional variant name (e.g., "PLAN", "ROUTER") */ variant: string | null, }; export type ExecutorRecentModels = { /** * Ordered list of recently used model keys (most recent last). */ models?: Array, /** * Last-used reasoning effort per model */ reasoning_by_model?: { [key in string]?: string }, }; export type ExecutorProfile = { recently_used_models?: ExecutorRecentModels | null, } & ({ [key in string]?: { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR_AGENT": CursorAgent } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot } | { "DROID": Droid } }); export type ExecutorConfigs = { executors: { [key in BaseCodingAgent]?: ExecutorProfile }, }; export enum BaseAgentCapability { SESSION_FORK = "SESSION_FORK", SETUP_HELPER = "SETUP_HELPER", CONTEXT_USAGE = "CONTEXT_USAGE" } export type ClaudeEffort = "low" | "medium" | "high" | "max"; export type ClaudeCode = { append_prompt: AppendPrompt, claude_code_router?: boolean | null, plan?: boolean | null, approvals?: boolean | null, model?: string | null, effort?: ClaudeEffort | null, agent?: string | null, dangerously_skip_permissions?: boolean | null, disable_api_key?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Gemini = { append_prompt: AppendPrompt, model?: string | null, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Amp = { append_prompt: AppendPrompt, dangerously_allow_all?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Codex = { append_prompt: AppendPrompt, sandbox?: SandboxMode | null, ask_for_approval?: AskForApproval | null, oss?: boolean | null, model?: string | null, model_reasoning_effort?: ReasoningEffort | null, model_reasoning_summary?: ReasoningSummary | null, model_reasoning_summary_format?: ReasoningSummaryFormat | null, profile?: string | null, base_instructions?: string | null, include_apply_patch_tool?: boolean | null, model_provider?: string | null, compact_prompt?: string | null, developer_instructions?: string | null, plan: boolean, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type SandboxMode = "auto" | "read-only" | "workspace-write" | "danger-full-access"; export type AskForApproval = "unless-trusted" | "on-failure" | "on-request" | "never"; export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; export type ReasoningSummary = "auto" | "concise" | "detailed" | "none"; export type ReasoningSummaryFormat = "none" | "experimental"; export type CursorAgent = { append_prompt: AppendPrompt, force?: boolean | null, model?: string | null, reasoning?: string | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Copilot = { append_prompt: AppendPrompt, model?: string | null, allow_all_tools?: boolean | null, allow_tool?: string | null, deny_tool?: string | null, add_dir?: Array | null, disable_mcp_server?: Array | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Opencode = { append_prompt: AppendPrompt, model?: string | null, variant?: string | null, agent?: string | null, /** * Auto-approve agent actions */ auto_approve: boolean, /** * Enable auto-compaction when the context length approaches the model's context window limit */ auto_compact: boolean, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type QwenCode = { append_prompt: AppendPrompt, model?: string | null, agent?: string | null, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Droid = { append_prompt: AppendPrompt, autonomy: Autonomy, model?: string | null, reasoning_effort?: DroidReasoningEffort | null, base_command_override?: string | null, additional_params?: Array | null, env?: { [key in string]?: string } | null, }; export type Autonomy = "normal" | "low" | "medium" | "high" | "skip-permissions-unsafe"; export type DroidReasoningEffort = "none" | "dynamic" | "off" | "low" | "medium" | "high"; export type AppendPrompt = string | null; export type CodingAgentInitialRequest = { prompt: string, /** * Unified executor identity + overrides */ executor_config: ExecutorConfig, /** * Optional relative path to execute the agent in (relative to container_ref). * If None, uses the container_ref directory directly. */ working_dir: string | null, }; export type CodingAgentFollowUpRequest = { prompt: string, session_id: string, reset_to_message_id: string | null, /** * Unified executor identity + overrides */ executor_config: ExecutorConfig, /** * Optional relative path to execute the agent in (relative to container_ref). * If None, uses the container_ref directory directly. */ working_dir: string | null, }; export type ReviewRequest = { /** * Unified executor identity + overrides */ executor_config: ExecutorConfig, context: Array | null, prompt: string, /** * Optional session ID to resume an existing session */ session_id: string | null, /** * Optional relative path to execute the agent in (relative to container_ref). */ working_dir: string | null, }; export type RepoReviewContext = { repo_id: string, repo_name: string, base_commit: string, }; export type CommandExitStatus = { "type": "exit_code", code: number, } | { "type": "success", success: boolean, }; export type CommandRunResult = { exit_status: CommandExitStatus | null, output: string | null, }; export type CommandCategory = "read" | "search" | "edit" | "fetch" | "other"; export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, }; export type NormalizedEntryType = { "type": "user_message" } | { "type": "user_feedback", denied_tool: string, } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, status: ToolStatus, } | { "type": "system_message" } | { "type": "error_message", error_type: NormalizedEntryError, } | { "type": "thinking" } | { "type": "loading" } | { "type": "next_action", failed: boolean, execution_processes: number, needs_setup: boolean, } | { "type": "token_usage_info" } & TokenUsageInfo | { "type": "user_answered_questions", answers: Array, }; export type TokenUsageInfo = { total_tokens: number, model_context_window: number, }; export type FileChange = { "action": "write", content: string, } | { "action": "delete" } | { "action": "rename", new_path: string, } | { "action": "edit", /** * Unified diff containing file header and hunks. */ unified_diff: string, /** * Whether line number in the hunks are reliable. */ has_line_numbers: boolean, }; export type ActionType = { "action": "file_read", path: string, } | { "action": "file_edit", path: string, changes: Array, } | { "action": "command_run", command: string, result: CommandRunResult | null, category: CommandCategory, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "tool", tool_name: string, arguments: JsonValue | null, result: ToolResult | null, } | { "action": "task_create", description: string, subagent_type: string | null, result: ToolResult | null, } | { "action": "plan_presentation", plan: string, } | { "action": "todo_management", todos: Array, operation: string, } | { "action": "ask_user_question", questions: Array, } | { "action": "other", description: string, }; export type AnsweredQuestion = { question: string, answer: Array, }; export type AskUserQuestionItem = { question: string, header: string, options: Array, multiSelect: boolean, }; export type AskUserQuestionOption = { label: string, description: string, }; export type TodoItem = { content: string, status: string, priority: string | null, }; export type NormalizedEntryError = { "type": "setup_required" } | { "type": "other" }; export type ToolResult = { type: ToolResultValueType, /** * For Markdown, this will be a JSON string; for JSON, a structured value */ value: JsonValue, }; export type ToolResultValueType = { "type": "markdown" } | { "type": "json" }; export type ToolStatus = { "status": "created" } | { "status": "success" } | { "status": "failed" } | { "status": "denied", reason: string | null, } | { "status": "pending_approval", approval_id: string, } | { "status": "timed_out" }; export type PatchType = { "type": "NORMALIZED_ENTRY", "content": NormalizedEntry } | { "type": "STDOUT", "content": string } | { "type": "STDERR", "content": string } | { "type": "DIFF", "content": Diff }; export type ModelInfo = { /** * Model identifier */ id: string, /** * Display name */ name: string, /** * Provider this model belongs to */ provider_id?: string | null, /** * Configurable reasoning options if supported */ reasoning_options: Array, }; export type ReasoningOption = { id: string, label: string, is_default: boolean, }; export type ModelProvider = { /** * Provider identifier */ id: string, /** * Display name */ name: string, }; export type AgentInfo = { id: string, label: string, description?: string | null, is_default: boolean, }; export enum PermissionPolicy { AUTO = "AUTO", SUPERVISED = "SUPERVISED", PLAN = "PLAN" } export type ModelSelectorConfig = { /** * Available providers */ providers: Array, /** * Available models */ models: Array, /** * Global default model (format: provider_id/model_id) */ default_model?: string | null, /** * Available agents */ agents: Array, /** * Supported permission policies */ permissions: Array, }; export type ExecutorDiscoveredOptions = { model_selector: ModelSelectorConfig, slash_commands: Array, loading_models: boolean, loading_agents: boolean, loading_slash_commands: boolean, error: string | null, }; export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; export const DEFAULT_PR_DESCRIPTION_PROMPT = "Update the PR that was just created with a better title and description.\nThe PR number is #{pr_number} and the URL is {pr_url}.\n\nAnalyze the changes in this branch and write:\n1. A concise, descriptive title that summarizes the changes, postfixed with \"(Vibe Kanban)\"\n2. A detailed description that explains:\n - What changes were made\n - Why they were made (based on the task context)\n - Any important implementation details\n - At the end, include a note: \"This PR was written using [Vibe Kanban](https://vibekanban.com)\"\n\nUse the appropriate CLI tool to update the PR (gh pr edit for GitHub, az repos pr update for Azure DevOps)."; export const DEFAULT_COMMIT_REMINDER_PROMPT = "There are uncommitted changes. Please stage and commit them now with a descriptive commit message.";