Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3327b9ef6 | |||
| d751055452 | |||
| b0ce83a937 | |||
| 3bb1272c6d | |||
| 5501558354 | |||
| f0a76fd82c | |||
| 2506713fad | |||
| a060666f6f | |||
| 4192798ceb | |||
| 4a12610662 | |||
| eddf3eff4e | |||
| 4d093548de | |||
| aaa593ac31 | |||
| 24a8dec151 | |||
| 9078b8d41a | |||
| d548525ef2 | |||
| 3aa1468fbb | |||
| f7f9a9ca09 | |||
| a37546cb2d | |||
| f43daed3ee | |||
| 35f63c46f1 | |||
| e296c01b4b | |||
| a6c4db64c4 | |||
| 7a02545fb8 | |||
| 9cad69b88c |
@@ -22,3 +22,12 @@
|
||||
# Default to all CPUs for parallel codegen. Override with --jobs N if
|
||||
# you need to leave headroom for other work.
|
||||
# jobs = 8
|
||||
|
||||
# sccache compilation cache: caches compiled crates keyed by source+flags,
|
||||
# so clean builds and branch switches reuse prior results. sccache transparently
|
||||
# skips crates built with incremental compilation (dev profile), so it mainly
|
||||
# accelerates release/fast-release and dependency rebuilds. Harmless if absent
|
||||
# — cargo only applies this when the binary exists on PATH via build-and-run.sh,
|
||||
# which sets RUSTC_WRAPPER conditionally. Uncomment to force it for direct cargo:
|
||||
# rustc-wrapper = "sccache"
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
@@ -0,0 +1,32 @@
|
||||
# Developer identity (local only)
|
||||
.developer
|
||||
|
||||
# Current task pointer (each dev works on different task)
|
||||
.current-task
|
||||
|
||||
# Session/window scoped runtime state
|
||||
.runtime/
|
||||
|
||||
# Ralph Loop state file
|
||||
.ralph-state.json
|
||||
|
||||
# Agent runtime files
|
||||
.agents/
|
||||
.agent-log
|
||||
.session-id
|
||||
|
||||
# Task directory runtime files
|
||||
.plan-log
|
||||
|
||||
# Atomic update temp files
|
||||
*.tmp
|
||||
|
||||
# Update backup directories
|
||||
.backup-*
|
||||
|
||||
# Conflict resolution temp files
|
||||
*.new
|
||||
|
||||
# Python cache
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"__version": 2,
|
||||
"hashes": {
|
||||
".claude/agents/trellis-check.md": "9e48342243f311d55386f8fb42933945e87aba73d5ade547133aa98345a06128",
|
||||
".claude/agents/trellis-implement.md": "73b56b3047c0e852382c4630aa181fb847d1e5ea1f63459dac4bf728f43fd097",
|
||||
".claude/agents/trellis-research.md": "add4aa4259ded425b04ec992802c646490352bb4eb3730a7ab450beea87d4faa",
|
||||
".claude/hooks/inject-subagent-context.py": "c8ea04062990530dffb26c4d1efa3e6887e042d3086da2f2325b491c9d544931",
|
||||
".claude/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec",
|
||||
".claude/hooks/session-start.py": "2cd87a08dec8cecc5f1acb665d6bd7241bc580f6ae39d571b99a0118be89c46b",
|
||||
".claude/commands/trellis/continue.md": "6c34c41824f8eff4b2df792e032e0c36787b59f8a8879b0338347073f76ad52c",
|
||||
".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc",
|
||||
".claude/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".claude/skills/trellis-brainstorm/SKILL.md": "219f99c61e59aa9af4642c0ec96ceec72c915f6757572760ba69dee951b05eb1",
|
||||
".claude/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".claude/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610",
|
||||
".claude/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".claude/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".claude/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".claude/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".claude/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".claude/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".claude/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".claude/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||
".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "ca4211b43aff79cf263c83255f557994386723d10da344c0c4e1ec211c7fcd37",
|
||||
".claude/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".claude/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".claude/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||
".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".claude/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||
".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "b70f983d0b3fea45e4ad7abef8ab8981473ff016d6d454d4d407865c13019d31",
|
||||
".claude/skills/trellis-meta/references/platform-files/overview.md": "7bc24f5ffefe082f373562bbba715cce9e761a76f6b59ce341d4dc679971a587",
|
||||
".claude/skills/trellis-meta/references/platform-files/platform-map.md": "a3cb8b4b4e42a3b380485aca673750ba3b2b28c16a04339b1db7afbd479509ab",
|
||||
".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||
".claude/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||
".claude/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".claude/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".claude/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||
".claude/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".claude/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".claude/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".claude/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".claude/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".agents/skills/trellis-continue/SKILL.md": "7723ccf49fbf19d8f086cacc7a080bd8be8db6fc70a32908b80f68efa318d7bf",
|
||||
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
||||
".agents/skills/trellis-before-dev/SKILL.md": "859894f2e8258cbfa142d710363433761c54984c69f7ed7bd44512fb4eb165cd",
|
||||
".agents/skills/trellis-brainstorm/SKILL.md": "219f99c61e59aa9af4642c0ec96ceec72c915f6757572760ba69dee951b05eb1",
|
||||
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".agents/skills/trellis-check/SKILL.md": "b21ff04b7680ebacb8c5ecbc48a22d627eb13e2b47fceb78c8ced0b43b60b282",
|
||||
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
||||
".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-agents.md": "3eef0d9b9cf875f121c1a38afb8eee6d3cd3894127db601ae5a7760fa5b61575",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "a6994e2418cdf5bcad10b4236b02741179ae794bc4fdd811a64f443293b69268",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "bac739b042d7a12dae5d14d85cbc312e56ca03f571d2ba7a25b20641ddaefc3f",
|
||||
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
|
||||
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "ca4211b43aff79cf263c83255f557994386723d10da344c0c4e1ec211c7fcd37",
|
||||
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "2b561d49c390f7d0db5391912946133be4bf73189231e2b8cc9afa1c5ac6165a",
|
||||
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".agents/skills/trellis-meta/references/platform-files/agents.md": "ce1a22c2b3ff0bff68755b5663e1f3b63ef2f1658cff43e511cee9013537c698",
|
||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "b70f983d0b3fea45e4ad7abef8ab8981473ff016d6d454d4d407865c13019d31",
|
||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "7bc24f5ffefe082f373562bbba715cce9e761a76f6b59ce341d4dc679971a587",
|
||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "a3cb8b4b4e42a3b380485aca673750ba3b2b28c16a04339b1db7afbd479509ab",
|
||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "5732bb0a7d384a91abd0c103372f3865e0b186e75185f35e0e91b00a6b6bf5a3",
|
||||
".agents/skills/trellis-meta/SKILL.md": "a190ecbde70a658a5b5c3e2558783e99c9afc6f2e5cdecdac8f11c06dd42065e",
|
||||
".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".agents/skills/trellis-session-insight/SKILL.md": "f6ad80eaca21b8fa63d31ae26b612bb2bb69dd04469603828509a8c5a0463ff1",
|
||||
".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||
".codex/agents/trellis-check.toml": "372dbd32a68f156727fd9f5d755a184543db96013abfeaffb09974c78fd5b873",
|
||||
".codex/agents/trellis-implement.toml": "87dc719802b355c61607c85ae0ab3e42b2a374a578a9fde349c3ae412638f8b4",
|
||||
".codex/agents/trellis-research.toml": "5492f7f6ab8bdea975b0e853bf171b050f7ddf6c2079ac770ed912c48d815eae",
|
||||
".codex/hooks/session-start.py": "1c951ff35f490c5fbf576b4764ec190895df7c2a48e279fb20625209f51c321a",
|
||||
".codex/hooks/inject-workflow-state.py": "f7fa9389ed7aa264597fff5de6277bec186e89a3ef539192997c6d026d88d5ec",
|
||||
".codex/hooks.json": "522ba3c488c100027783e52ecff84c0bd799852dd77ad3f1936e86db105f01d6",
|
||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||
".trellis/config.yaml": "2e6489de5ab99c501f24edd3f08df977801bf49c33b04a2de6afafe35a3a8332",
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "7fcacb29ffd13599949a517aa967d868040eb4c9b40356b0c460a32361237eb6",
|
||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
||||
".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f",
|
||||
".trellis/scripts/common/cli_adapter.py": "bda2eb3a76b3990ef979846a5eff3d8aedde9c70e0e9d128266034aeee186020",
|
||||
".trellis/scripts/common/config.py": "25c5a53ad20d6909be5209222e4208a84528805316a4d78350529459a364edb1",
|
||||
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||
".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6",
|
||||
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||
".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8",
|
||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
||||
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
|
||||
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||
".trellis/scripts/common/session_context.py": "df79c44efe3432811c32d145d57a66343a70e221ec087ed2bd28b76677bb4076",
|
||||
".trellis/scripts/common/task_context.py": "d174684d417bbe2fafc26b6afcddb264c7dc519527bb24d2055cd27daaad9b55",
|
||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||
".trellis/scripts/common/task_store.py": "789c090c2d5103bf790e8bfb0989a8ff486c7d370cbe03d3eefb1c239d02624a",
|
||||
".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e",
|
||||
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
|
||||
".trellis/scripts/common/trellis_config.py": "0839dcf90ebbd77712c276930a89335b3313927051650c91d220fb51ca2a6a3c",
|
||||
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||
".trellis/scripts/common/workflow_phase.py": "f2b5fcf0c40cedcf3d7d0ad8023d141fa4095caf0302d73f2a73e1bc04b5692b",
|
||||
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||
".trellis/scripts/task.py": "6c65801a1f56648fd4765a1d216493d3094827c1db4761e55fdaa548c1801798",
|
||||
".trellis/workflow.md": "f44acd1c08d1601fdc4377671ef932eb3e09f0e87b7872a6d0ee4baeada29fcb"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0.6.3
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: check
|
||||
description: |
|
||||
Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results.
|
||||
provider: claude
|
||||
labels: [trellis, check]
|
||||
---
|
||||
|
||||
# Check Agent (channel runtime)
|
||||
|
||||
You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before reviewing, read in this order:
|
||||
|
||||
1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes
|
||||
2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)?
|
||||
3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/`
|
||||
4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have
|
||||
5. **Run verification** — project lint and typecheck on the changed scope
|
||||
6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report the post-fix state; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run `git diff --name-only` and `git diff` to scope the changes
|
||||
2. Read the task artifacts and relevant spec files
|
||||
3. For each issue:
|
||||
- If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place
|
||||
- If a design/judgment issue → record and report, do not silently rewrite
|
||||
4. Run the project's lint and typecheck on the changed scope after self-fixes
|
||||
5. Report
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Self-Check Complete
|
||||
|
||||
### Files Checked
|
||||
- <path>
|
||||
|
||||
### Issues Found and Fixed
|
||||
1. `<file>:<line>` — <what was wrong> → <what you changed>
|
||||
|
||||
### Issues Not Fixed
|
||||
- `<file>:<line>` — <issue> — <why deferred to the main session>
|
||||
|
||||
### Verification Results
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
|
||||
### Summary
|
||||
Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open.
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: implement
|
||||
description: |
|
||||
Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed.
|
||||
provider: claude
|
||||
labels: [trellis, implement]
|
||||
---
|
||||
|
||||
# Implement Agent (channel runtime)
|
||||
|
||||
You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before implementing, read in this order:
|
||||
|
||||
1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Understand specs** — read relevant spec files in `.trellis/spec/`
|
||||
2. **Understand task artifacts** — read the artifacts listed above
|
||||
3. **Implement features** — write code that follows specs and existing patterns
|
||||
4. **Self-check** — run lint and typecheck on the changed scope before reporting
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report what changed; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read relevant specs based on task type and the files in `implement.jsonl` if present
|
||||
2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
3. Implement features following specs and existing patterns
|
||||
4. Run the project's lint and typecheck commands on the changed scope
|
||||
5. Report files touched, key decisions, and verification results back to the channel
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow existing code patterns
|
||||
- Don't add unnecessary abstractions
|
||||
- Only do what the PRD asks for; no speculative scope expansion
|
||||
- Surface uncertainty back to the channel rather than guessing
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
### Files Modified
|
||||
- <path> — <one-line description>
|
||||
|
||||
### Implementation Summary
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
### Verification Results
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
|
||||
### Open Questions
|
||||
- <if any, otherwise omit>
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Trellis Configuration
|
||||
# Project-level settings for the Trellis workflow system
|
||||
#
|
||||
# All values have sensible defaults. Only override what you need.
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Session Recording
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Commit message used when auto-committing journal/index changes
|
||||
# after running add_session.py
|
||||
session_commit_message: "chore: record journal"
|
||||
|
||||
# Maximum lines per journal file before rotating to a new one
|
||||
max_journal_lines: 2000
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Session Auto-Commit
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Auto-commit behavior for session journal + task archive operations.
|
||||
# - true (default): scripts auto-stage and auto-commit journal / task changes
|
||||
# after add_session.py / task.py archive runs.
|
||||
# - false: scripts do not touch git. Files (journal-*.md, task archive moves)
|
||||
# are still written to disk; you decide whether to git add / commit.
|
||||
#
|
||||
# Use `false` if your project's .gitignore intentionally excludes `.trellis/`
|
||||
# and you want session data kept local-only, or if you prefer to review
|
||||
# staged changes manually before each commit.
|
||||
#
|
||||
# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
|
||||
#
|
||||
# session_auto_commit: true
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Task Lifecycle Hooks
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Shell commands to run after task lifecycle events.
|
||||
# Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
|
||||
# Hook failures print a warning but do not block the main operation.
|
||||
#
|
||||
# hooks:
|
||||
# after_create:
|
||||
# - "echo 'Task created'"
|
||||
# after_start:
|
||||
# - "echo 'Task started'"
|
||||
# after_finish:
|
||||
# - "echo 'Task finished'"
|
||||
# after_archive:
|
||||
# - "echo 'Task archived'"
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Monorepo / Packages
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Declare packages for monorepo projects.
|
||||
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
||||
# configure them manually here.
|
||||
#
|
||||
# packages:
|
||||
# frontend:
|
||||
# path: packages/frontend
|
||||
# backend:
|
||||
# path: packages/backend
|
||||
# docs:
|
||||
# path: docs-site
|
||||
# type: submodule
|
||||
# # For polyrepo / meta-repo layouts (independent .git in each subdir),
|
||||
# # mark the package with `git: true`. The runtime treats it as an
|
||||
# # independent repository for things like git-context display.
|
||||
# webapp:
|
||||
# path: ./webapp
|
||||
# git: true
|
||||
|
||||
# Default package used when --package is not specified.
|
||||
# default_package: frontend
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Channel worker OOM guard
|
||||
#-------------------------------------------------------------------------------
|
||||
# Default safeguards for `trellis channel spawn` workers. The guard runs
|
||||
# at spawn time (cleans expired idle workers, then enforces the live-worker
|
||||
# budget) and inside each supervisor (self-terminates a worker that stays
|
||||
# continuously idle past `idle_timeout`).
|
||||
#
|
||||
# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT /
|
||||
# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default.
|
||||
#
|
||||
# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever
|
||||
# unless explicitly killed or given `--timeout`).
|
||||
# `max_live_workers: 0` disables the spawn-time budget check.
|
||||
#
|
||||
channel:
|
||||
worker_guard:
|
||||
idle_timeout: 5m
|
||||
max_live_workers: 6
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex (dispatch behavior)
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex-only knob; other platforms ignore it. Default ("inline") makes the
|
||||
# main Codex agent edit code directly because Codex sub-agents run with
|
||||
# `fork_turns="none"` isolation and can't inherit the parent session's
|
||||
# task context. Set to "sub-agent" to opt into the legacy dispatch model
|
||||
# (main agent spawns trellis-implement / trellis-check / trellis-research
|
||||
# sub-agents).
|
||||
#
|
||||
# codex:
|
||||
# dispatch_mode: inline # or "sub-agent" to dispatch trellis-* sub-agents
|
||||
|
||||
# Auto-detected monorepo packages
|
||||
packages:
|
||||
vendor/ComfyUI_frontend:
|
||||
path: vendor/ComfyUI_frontend
|
||||
type: submodule
|
||||
default_package: vendor/ComfyUI_frontend
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Trellis Python Scripts
|
||||
|
||||
This module provides Python implementations of Trellis workflow scripts.
|
||||
"""
|
||||
Executable
+567
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Add a new session to journal file and update index.md.
|
||||
|
||||
Usage:
|
||||
python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
|
||||
python3 add_session.py --title "Title" --branch "feat/my-branch"
|
||||
|
||||
# Pipe detailed content via stdin (use --stdin to opt in):
|
||||
cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary"
|
||||
<session content here>
|
||||
EOF
|
||||
|
||||
Branch resolution order:
|
||||
1. --branch CLI arg (explicit)
|
||||
2. task.json branch field (from active task)
|
||||
3. git branch --show-current (auto-detect)
|
||||
4. None (omitted gracefully)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common.paths import (
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_current_task,
|
||||
get_developer,
|
||||
get_workspace_dir,
|
||||
)
|
||||
from common.developer import ensure_developer
|
||||
from common.git import run_git
|
||||
from common.safe_commit import (
|
||||
print_gitignore_warning,
|
||||
safe_git_add,
|
||||
safe_trellis_paths_to_add,
|
||||
)
|
||||
from common.tasks import load_task
|
||||
from common.config import (
|
||||
get_packages,
|
||||
get_session_auto_commit,
|
||||
get_session_commit_message,
|
||||
get_max_journal_lines,
|
||||
is_monorepo,
|
||||
resolve_package,
|
||||
validate_package,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]:
|
||||
"""Get latest journal file info.
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, file_number, line_count).
|
||||
"""
|
||||
latest_file: Path | None = None
|
||||
latest_num = -1
|
||||
|
||||
for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
match = re.search(r"(\d+)$", f.stem)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if num > latest_num:
|
||||
latest_num = num
|
||||
latest_file = f
|
||||
|
||||
if latest_file:
|
||||
lines = len(latest_file.read_text(encoding="utf-8").splitlines())
|
||||
return latest_file, latest_num, lines
|
||||
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
def get_current_session(index_file: Path) -> int:
|
||||
"""Get current session number from index.md."""
|
||||
if not index_file.is_file():
|
||||
return 0
|
||||
|
||||
content = index_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if "Total Sessions" in line:
|
||||
match = re.search(r":\s*(\d+)", line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_journal_num(filename: str) -> int:
|
||||
"""Extract journal number from filename for sorting."""
|
||||
match = re.search(r"(\d+)", filename)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def count_journal_files(dev_dir: Path, active_num: int) -> str:
|
||||
"""Count journal files and return table rows."""
|
||||
active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md"
|
||||
result_lines = []
|
||||
|
||||
files = sorted(
|
||||
[f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()],
|
||||
key=lambda f: _extract_journal_num(f.stem),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for f in files:
|
||||
filename = f.name
|
||||
lines = len(f.read_text(encoding="utf-8").splitlines())
|
||||
status = "Active" if filename == active_file else "Archived"
|
||||
result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def create_new_journal_file(
|
||||
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
|
||||
) -> Path:
|
||||
"""Create a new journal file."""
|
||||
prev_num = num - 1
|
||||
new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
|
||||
|
||||
content = f"""# Journal - {developer} (Part {num})
|
||||
|
||||
> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines)
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
new_file.write_text(content, encoding="utf-8")
|
||||
return new_file
|
||||
|
||||
|
||||
def generate_session_content(
|
||||
session_num: int,
|
||||
title: str,
|
||||
commit: str,
|
||||
summary: str,
|
||||
extra_content: str,
|
||||
today: str,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> str:
|
||||
"""Generate session content."""
|
||||
if commit and commit != "-":
|
||||
commit_table = """| Hash | Message |
|
||||
|------|---------|"""
|
||||
for c in commit.split(","):
|
||||
c = c.strip()
|
||||
commit_table += f"\n| `{c}` | (see git log) |"
|
||||
else:
|
||||
commit_table = "(No commits - planning session)"
|
||||
|
||||
package_line = f"\n**Package**: {package}" if package else ""
|
||||
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
|
||||
|
||||
return f"""
|
||||
|
||||
## Session {session_num}: {title}
|
||||
|
||||
**Date**: {today}
|
||||
**Task**: {title}{package_line}{branch_line}
|
||||
|
||||
### Summary
|
||||
|
||||
{summary}
|
||||
|
||||
### Main Changes
|
||||
|
||||
{extra_content}
|
||||
|
||||
### Git Commits
|
||||
|
||||
{commit_table}
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
"""
|
||||
|
||||
|
||||
def update_index(
|
||||
index_file: Path,
|
||||
dev_dir: Path,
|
||||
title: str,
|
||||
commit: str,
|
||||
new_session: int,
|
||||
active_file: str,
|
||||
today: str,
|
||||
branch: str | None = None,
|
||||
) -> bool:
|
||||
"""Update index.md with new session info."""
|
||||
# Format commit for display
|
||||
commit_display = "-"
|
||||
if commit and commit != "-":
|
||||
commit_display = re.sub(r"([a-f0-9]{7,})", r"`\1`", commit.replace(",", ", "))
|
||||
|
||||
# Get file number from active_file name
|
||||
match = re.search(r"(\d+)", active_file)
|
||||
active_num = int(match.group(1)) if match else 0
|
||||
files_table = count_journal_files(dev_dir, active_num)
|
||||
|
||||
print(f"Updating index.md for session {new_session}...")
|
||||
print(f" Title: {title}")
|
||||
print(f" Commit: {commit_display}")
|
||||
print(f" Active File: {active_file}")
|
||||
print()
|
||||
|
||||
content = index_file.read_text(encoding="utf-8")
|
||||
|
||||
if "@@@auto:current-status" not in content:
|
||||
print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Process sections
|
||||
lines = content.splitlines()
|
||||
new_lines = []
|
||||
|
||||
in_current_status = False
|
||||
in_active_documents = False
|
||||
in_session_history = False
|
||||
header_written = False
|
||||
|
||||
for line in lines:
|
||||
if "@@@auto:current-status" in line:
|
||||
new_lines.append(line)
|
||||
in_current_status = True
|
||||
new_lines.append(f"- **Active File**: `{active_file}`")
|
||||
new_lines.append(f"- **Total Sessions**: {new_session}")
|
||||
new_lines.append(f"- **Last Active**: {today}")
|
||||
continue
|
||||
|
||||
if "@@@/auto:current-status" in line:
|
||||
in_current_status = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if "@@@auto:active-documents" in line:
|
||||
new_lines.append(line)
|
||||
in_active_documents = True
|
||||
new_lines.append("| File | Lines | Status |")
|
||||
new_lines.append("|------|-------|--------|")
|
||||
new_lines.append(files_table)
|
||||
continue
|
||||
|
||||
if "@@@/auto:active-documents" in line:
|
||||
in_active_documents = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if "@@@auto:session-history" in line:
|
||||
new_lines.append(line)
|
||||
in_session_history = True
|
||||
header_written = False
|
||||
continue
|
||||
|
||||
if "@@@/auto:session-history" in line:
|
||||
in_session_history = False
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
if in_current_status:
|
||||
continue
|
||||
|
||||
if in_active_documents:
|
||||
continue
|
||||
|
||||
if in_session_history:
|
||||
# Migrate old 4/6-column headers to 5-column Branch-only history.
|
||||
if re.match(
|
||||
r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
|
||||
line,
|
||||
):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
|
||||
new_lines.append("| # | Date | Title | Commits | Branch |")
|
||||
continue
|
||||
if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
|
||||
new_lines.append("|---|------|-------|---------|--------|")
|
||||
new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |")
|
||||
header_written = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
new_lines.append(line)
|
||||
|
||||
index_file.write_text("\n".join(new_lines), encoding="utf-8")
|
||||
print("[OK] Updated index.md successfully!")
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Function
|
||||
# =============================================================================
|
||||
|
||||
def _auto_commit_workspace(repo_root: Path) -> None:
|
||||
"""Stage Trellis-owned workspace + current-task paths and commit.
|
||||
|
||||
Path scope is restricted to specific products: the current developer's
|
||||
journal files + index.md, and ONLY the current task directory (resolved
|
||||
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
|
||||
or iterate over all active task dirs (#303: parallel-window dirty task
|
||||
dirs must not be bundled into the session auto-commit). If `.gitignore`
|
||||
blocks the specific paths we warn + skip — never retry with ``-f``.
|
||||
|
||||
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
|
||||
``false``, this function returns immediately without touching git
|
||||
(journal/index files are still written to disk by the caller).
|
||||
"""
|
||||
if not get_session_auto_commit(repo_root):
|
||||
print(
|
||||
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
commit_msg = get_session_commit_message(repo_root)
|
||||
# Resolve the current task so staging is scoped to its dir only. The ref
|
||||
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
|
||||
current = get_current_task(repo_root)
|
||||
if current:
|
||||
task_name = Path(current).name
|
||||
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
|
||||
else:
|
||||
# Current task unknown (0 or >=2 parallel sessions — exactly the
|
||||
# parallel-window case #303 is about). Do NOT fall back to the wide
|
||||
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
|
||||
# dirs into the session commit. Stage only the developer's journal/
|
||||
# index and skip every task dir.
|
||||
paths = [
|
||||
p
|
||||
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
|
||||
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
]
|
||||
if not paths:
|
||||
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||
return
|
||||
|
||||
success, _, err = safe_git_add(paths, repo_root)
|
||||
if not success:
|
||||
if err and "ignored by" in err.lower():
|
||||
print_gitignore_warning(paths)
|
||||
else:
|
||||
print(
|
||||
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if there are staged changes for the paths we just staged.
|
||||
rc, _, _ = run_git(
|
||||
["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
|
||||
)
|
||||
if rc == 0:
|
||||
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||
return
|
||||
|
||||
rc, _, commit_err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||
if rc == 0:
|
||||
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
f"[WARN] Auto-commit failed: {commit_err.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def add_session(
|
||||
title: str,
|
||||
commit: str = "-",
|
||||
summary: str = "(Add summary)",
|
||||
extra_content: str = "(Add details)",
|
||||
auto_commit: bool = True,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> int:
|
||||
"""Add a new session."""
|
||||
repo_root = get_repo_root()
|
||||
ensure_developer(repo_root)
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
print("Error: Developer not initialized", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dev_dir = get_workspace_dir(repo_root)
|
||||
if not dev_dir:
|
||||
print("Error: Workspace directory not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
max_lines = get_max_journal_lines(repo_root)
|
||||
|
||||
index_file = dev_dir / "index.md"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
|
||||
current_session = get_current_session(index_file)
|
||||
new_session = current_session + 1
|
||||
|
||||
session_content = generate_session_content(
|
||||
new_session, title, commit, summary, extra_content, today, package,
|
||||
branch,
|
||||
)
|
||||
content_lines = len(session_content.splitlines())
|
||||
|
||||
print("========================================", file=sys.stderr)
|
||||
print("ADD SESSION", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(f"Session: {new_session}", file=sys.stderr)
|
||||
print(f"Title: {title}", file=sys.stderr)
|
||||
print(f"Commit: {commit}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
|
||||
print(f"Current lines: {current_lines}", file=sys.stderr)
|
||||
print(f"New content lines: {content_lines}", file=sys.stderr)
|
||||
print(f"Total after append: {current_lines + content_lines}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
||||
target_file = journal_file
|
||||
target_num = current_num
|
||||
|
||||
if current_lines + content_lines > max_lines:
|
||||
target_num = current_num + 1
|
||||
print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
|
||||
target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
|
||||
print(f"Created: {target_file}", file=sys.stderr)
|
||||
|
||||
# Append session content
|
||||
if target_file:
|
||||
with target_file.open("a", encoding="utf-8") as f:
|
||||
f.write(session_content)
|
||||
print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
|
||||
|
||||
print("", file=sys.stderr)
|
||||
|
||||
# Update index.md
|
||||
active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
|
||||
if not update_index(
|
||||
index_file,
|
||||
dev_dir,
|
||||
title,
|
||||
commit,
|
||||
new_session,
|
||||
active_file,
|
||||
today,
|
||||
branch,
|
||||
):
|
||||
return 1
|
||||
|
||||
print("", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print(f"[OK] Session {new_session} added successfully!", file=sys.stderr)
|
||||
print("========================================", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("Files updated:", file=sys.stderr)
|
||||
print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
|
||||
print(" - index.md", file=sys.stderr)
|
||||
|
||||
# Auto-commit workspace changes
|
||||
if auto_commit:
|
||||
print("", file=sys.stderr)
|
||||
_auto_commit_workspace(repo_root)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add a new session to journal file and update index.md"
|
||||
)
|
||||
parser.add_argument("--title", required=True, help="Session title")
|
||||
parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
|
||||
parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
|
||||
parser.add_argument("--content-file", help="Path to file with detailed content")
|
||||
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
|
||||
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
|
||||
parser.add_argument("--no-commit", action="store_true",
|
||||
help="Skip auto-commit of workspace changes")
|
||||
parser.add_argument("--stdin", action="store_true",
|
||||
help="Read extra content from stdin (explicit opt-in)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
extra_content = "(Add details)"
|
||||
if args.content_file:
|
||||
content_path = Path(args.content_file)
|
||||
if content_path.is_file():
|
||||
extra_content = content_path.read_text(encoding="utf-8")
|
||||
elif args.stdin:
|
||||
extra_content = sys.stdin.read()
|
||||
|
||||
# Load active task once — shared by package and branch resolution
|
||||
repo_root = get_repo_root()
|
||||
current = get_current_task(repo_root)
|
||||
task_data = load_task(repo_root / current) if current else None
|
||||
|
||||
package = args.package
|
||||
if package:
|
||||
# CLI source: fail-fast in monorepo, ignore in single-repo
|
||||
if not is_monorepo(repo_root):
|
||||
print("Warning: --package ignored in single-repo project", file=sys.stderr)
|
||||
package = None
|
||||
elif not validate_package(package, repo_root):
|
||||
packages = get_packages(repo_root)
|
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||||
print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
# Inferred: active task's task.json.package → default_package → None
|
||||
task_package = task_data.package if task_data else None
|
||||
package = resolve_package(task_package, repo_root)
|
||||
|
||||
# Resolve branch: CLI → task.json → git auto-detect → None
|
||||
branch = args.branch
|
||||
|
||||
if not branch:
|
||||
if task_data and task_data.raw.get("branch"):
|
||||
branch = task_data.raw["branch"]
|
||||
else:
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
detected = branch_out.strip()
|
||||
if detected:
|
||||
branch = detected
|
||||
|
||||
return add_session(
|
||||
args.title, args.commit, args.summary, extra_content,
|
||||
auto_commit=not args.no_commit,
|
||||
package=package,
|
||||
branch=branch,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Common utilities for Trellis workflow scripts.
|
||||
|
||||
This module provides shared functionality used by other Trellis scripts.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# =============================================================================
|
||||
# Windows Encoding Fix (MUST be at top, before any other output)
|
||||
# =============================================================================
|
||||
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
||||
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
||||
#
|
||||
# Any script that imports from common will automatically get this fix.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _configure_stream(stream: object) -> object:
|
||||
"""Configure a stream for UTF-8 encoding on Windows."""
|
||||
# Try reconfigure() first (Python 3.7+, more reliable)
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
return stream
|
||||
# Fallback: detach and rewrap with TextIOWrapper
|
||||
elif hasattr(stream, "detach"):
|
||||
return io.TextIOWrapper(
|
||||
stream.detach(), # type: ignore[union-attr]
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return stream
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
def configure_encoding() -> None:
|
||||
"""
|
||||
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
||||
|
||||
This is automatically called when importing from common,
|
||||
but can be called manually for scripts that don't import common.
|
||||
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
global sys
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
from .paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
DIR_ARCHIVE,
|
||||
DIR_SPEC,
|
||||
DIR_SCRIPTS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_CURRENT_TASK,
|
||||
FILE_TASK_JSON,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
get_tasks_dir,
|
||||
get_workspace_dir,
|
||||
get_active_journal_file,
|
||||
count_lines,
|
||||
get_current_task,
|
||||
get_current_task_abs,
|
||||
normalize_task_ref,
|
||||
resolve_task_ref,
|
||||
set_current_task,
|
||||
clear_current_task,
|
||||
has_current_task,
|
||||
generate_task_date_prefix,
|
||||
)
|
||||
|
||||
from .active_task import (
|
||||
ActiveTask,
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
Executable
+626
@@ -0,0 +1,626 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Session-scoped active task resolution.
|
||||
|
||||
The user-facing concept is a single "active task". Trellis stores that pointer
|
||||
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
||||
session key there is no active task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||
|
||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
||||
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
||||
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
||||
_KNOWN_PLATFORMS = {
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"gemini",
|
||||
"droid",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"kiro",
|
||||
"copilot",
|
||||
"pi",
|
||||
}
|
||||
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
|
||||
("gemini", ("GEMINI_SESSION_ID",)),
|
||||
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
|
||||
("qoder", ("QODER_SESSION_ID",)),
|
||||
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
)
|
||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||
)
|
||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
|
||||
)
|
||||
_ENV_PLATFORM_ALIASES = {
|
||||
"claude-code": "claude",
|
||||
"factory": "droid",
|
||||
"factory-ai": "droid",
|
||||
"github-copilot": "copilot",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveTask:
|
||||
"""Resolved active task state."""
|
||||
|
||||
task_path: str | None
|
||||
source_type: str
|
||||
context_key: str | None = None
|
||||
stale: bool = False
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
"""Human-readable source label."""
|
||||
if self.source_type == "session" and self.context_key:
|
||||
return f"session:{self.context_key}"
|
||||
if self.source_type == "session-fallback" and self.context_key:
|
||||
return f"session-fallback:{self.context_key}"
|
||||
return self.source_type
|
||||
|
||||
|
||||
def normalize_task_ref(task_ref: str) -> str:
|
||||
"""Normalize a task ref for stable storage and comparison."""
|
||||
normalized = task_ref.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||
return f"{DIR_WORKFLOW}/{normalized}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a task ref to an absolute task directory."""
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return path_obj
|
||||
|
||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
return repo_root / path_obj
|
||||
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
|
||||
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
||||
|
||||
|
||||
def _sanitize_key(raw: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
|
||||
safe = safe.strip("._-")
|
||||
return safe[:160] if safe else ""
|
||||
|
||||
|
||||
def _hash_value(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||
for key in keys:
|
||||
value = _string_value(data.get(key))
|
||||
if value:
|
||||
return value
|
||||
|
||||
for nested_key in _NESTED_KEYS:
|
||||
nested = _as_dict(data.get(nested_key))
|
||||
if not nested:
|
||||
continue
|
||||
value = _lookup_string(nested, keys)
|
||||
if value:
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
|
||||
if platform:
|
||||
return _sanitize_key(platform) or "session"
|
||||
if platform_input:
|
||||
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
|
||||
value = _string_value(platform_input.get(key))
|
||||
if value:
|
||||
return _sanitize_key(value) or "session"
|
||||
if _string_value(platform_input.get("cursor_version")):
|
||||
return "cursor"
|
||||
return "session"
|
||||
|
||||
|
||||
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
||||
if kind == "transcript":
|
||||
return f"{platform_name}_transcript_{_hash_value(value)}"
|
||||
safe_value = _sanitize_key(value)
|
||||
if safe_value:
|
||||
return f"{platform_name}_{safe_value}"
|
||||
return f"{platform_name}_{_hash_value(value)}"
|
||||
|
||||
|
||||
def _iter_env_keys(
|
||||
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
platform_name: str | None,
|
||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||
if not platform_name:
|
||||
return env_keys
|
||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
||||
return matched
|
||||
|
||||
|
||||
def _env_platform_name(platform_name: str | None) -> str | None:
|
||||
if not platform_name or platform_name == "session":
|
||||
return None
|
||||
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||
|
||||
|
||||
def _lookup_env_context_key(platform_name: str | None) -> str | None:
|
||||
"""Resolve a context key from platform-provided environment variables.
|
||||
|
||||
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
|
||||
shell command can only see session identity if the host platform exports it
|
||||
in the command environment. These names are best-effort adapters; if none
|
||||
are present, there is no session-scoped active task.
|
||||
"""
|
||||
env_platform_name = _env_platform_name(platform_name)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "session", value)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "conversation", value)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "transcript", value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_repo_root_from_cwd() -> Path | None:
|
||||
current = Path.cwd().resolve()
|
||||
while True:
|
||||
if (current / DIR_WORKFLOW).is_dir():
|
||||
return current
|
||||
if current == current.parent:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
||||
|
||||
|
||||
def _remove_file(path: Path) -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
left_path = resolve_task_ref(left, repo_root)
|
||||
right_path = resolve_task_ref(right, repo_root)
|
||||
if left_path is not None and right_path is not None:
|
||||
return left_path == right_path
|
||||
return normalize_task_ref(left) == normalize_task_ref(right)
|
||||
|
||||
|
||||
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
if Path(sys.argv[0]).name != "task.py":
|
||||
return False
|
||||
args = tuple(sys.argv[1:])
|
||||
if not args:
|
||||
return False
|
||||
|
||||
command_name = args[0]
|
||||
if command_name not in TASK_SESSION_COMMANDS:
|
||||
return False
|
||||
|
||||
subcommands = ticket.get("subcommands")
|
||||
if not isinstance(subcommands, list):
|
||||
return False
|
||||
|
||||
for subcommand in subcommands:
|
||||
if not isinstance(subcommand, dict):
|
||||
continue
|
||||
if _string_value(subcommand.get("name")) != command_name:
|
||||
continue
|
||||
if command_name != "start":
|
||||
return True
|
||||
task_ref = args[1] if len(args) > 1 else None
|
||||
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
|
||||
expires_at = ticket.get("expires_at_epoch")
|
||||
if isinstance(expires_at, (int, float)) and expires_at < now:
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
|
||||
created_at = ticket.get("created_at_epoch")
|
||||
if isinstance(created_at, (int, float)):
|
||||
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
||||
return True
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
cwd = _string_value(ticket.get("cwd"))
|
||||
if not cwd:
|
||||
return True
|
||||
try:
|
||||
Path(cwd).resolve().relative_to(repo_root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _matching_cursor_ticket_context_key(
|
||||
ticket_path: Path,
|
||||
repo_root: Path,
|
||||
now: float,
|
||||
) -> str | None:
|
||||
ticket = _read_json(ticket_path)
|
||||
if ticket is None or ticket.get("platform") != "cursor":
|
||||
return None
|
||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||
return None
|
||||
if not _ticket_cwd_matches_repo(ticket, repo_root):
|
||||
return None
|
||||
if not _pending_ticket_matches_args(ticket, repo_root):
|
||||
return None
|
||||
return _string_value(ticket.get("context_key"))
|
||||
|
||||
|
||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
||||
|
||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
||||
export it into the shell command environment. The Cursor hook writes a
|
||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
||||
the current `task.py` subcommand matches and exactly one fresh context key
|
||||
matches, which avoids cross-window pointer contamination.
|
||||
"""
|
||||
repo_root = _find_repo_root_from_cwd()
|
||||
if repo_root is None:
|
||||
return None
|
||||
|
||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
||||
if not ticket_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return next(iter(candidates))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_context_key(
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve a stable session/window context key, if one is available.
|
||||
|
||||
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
||||
scripts and subprocesses. It does not store the task itself.
|
||||
"""
|
||||
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
||||
if override:
|
||||
return _sanitize_key(override) or _hash_value(override)
|
||||
|
||||
data = _as_dict(platform_input)
|
||||
platform_name = _detect_platform(data, platform) if data or platform else None
|
||||
|
||||
if data:
|
||||
session_id = _lookup_string(data, _SESSION_KEYS)
|
||||
if session_id:
|
||||
return _context_key(platform_name or "session", "session", session_id)
|
||||
|
||||
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
|
||||
if conversation_id:
|
||||
return _context_key(platform_name or "session", "conversation", conversation_id)
|
||||
|
||||
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
|
||||
if transcript_path:
|
||||
return _context_key(platform_name or "session", "transcript", transcript_path)
|
||||
|
||||
env_context_key = _lookup_env_context_key(platform_name)
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
if platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_shell_ticket_context_key()
|
||||
return None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
||||
normalized = normalize_task_ref(task_path)
|
||||
if not normalized:
|
||||
return None
|
||||
full_path = resolve_task_ref(normalized, repo_root)
|
||||
if full_path is None or not full_path.is_dir():
|
||||
return None
|
||||
try:
|
||||
return full_path.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
return str(full_path)
|
||||
|
||||
|
||||
def _active_from_ref(
|
||||
task_ref: str | None,
|
||||
repo_root: Path,
|
||||
source_type: str,
|
||||
context_key: str | None = None,
|
||||
) -> ActiveTask | None:
|
||||
if not task_ref:
|
||||
return None
|
||||
resolved = resolve_task_ref(task_ref, repo_root)
|
||||
stale = resolved is None or not resolved.is_dir()
|
||||
return ActiveTask(task_ref, source_type, context_key, stale)
|
||||
|
||||
|
||||
def _context_path(repo_root: Path, context_key: str) -> Path:
|
||||
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
|
||||
|
||||
|
||||
def resolve_active_task(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask:
|
||||
"""Resolve the active task from session runtime state only.
|
||||
|
||||
A stale session task is returned as stale. Missing context identity or a
|
||||
missing/empty session context falls back to single-session inference: if
|
||||
exactly one session file exists in the runtime, return its task with
|
||||
source_type="session-fallback" — covers class-2 platform sub-agents (codex,
|
||||
copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
||||
files or 0 files yield ActiveTask(None) — refuses to guess across windows.
|
||||
"""
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if context_key:
|
||||
context = _read_json(_context_path(repo_root, context_key)) or {}
|
||||
task_ref = _string_value(context.get("current_task"))
|
||||
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
||||
if active:
|
||||
return active
|
||||
|
||||
fallback = _resolve_single_session_fallback(repo_root)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
return ActiveTask(None, "none", context_key)
|
||||
|
||||
|
||||
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
|
||||
"""Return the task pointed at by the sole session file, if exactly one exists.
|
||||
|
||||
Used when context-key resolution fails (typical for class-2 platform
|
||||
sub-agents). Returns None if 0 or ≥2 session files are present — refuses
|
||||
to pick across windows so 04-21's multi-session isolation contract holds.
|
||||
"""
|
||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||
if not sessions_dir.is_dir():
|
||||
return None
|
||||
|
||||
session_files = sorted(sessions_dir.glob("*.json"))
|
||||
if len(session_files) != 1:
|
||||
return None
|
||||
|
||||
session_file = session_files[0]
|
||||
context = _read_json(session_file) or {}
|
||||
task_ref = _string_value(context.get("current_task"))
|
||||
if not task_ref:
|
||||
return None
|
||||
|
||||
fallback_key = session_file.stem
|
||||
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _context_metadata(
|
||||
platform_input: dict[str, Any] | None,
|
||||
platform: str | None,
|
||||
context_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = _as_dict(platform_input) or {}
|
||||
platform_name = _detect_platform(data, platform)
|
||||
if platform_name == "session" and context_key:
|
||||
prefix = context_key.split("_", 1)[0]
|
||||
if prefix in _KNOWN_PLATFORMS:
|
||||
platform_name = prefix
|
||||
metadata: dict[str, Any] = {
|
||||
"platform": platform_name,
|
||||
"last_seen_at": _utc_now(),
|
||||
}
|
||||
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
|
||||
value = _lookup_string(data, (key,))
|
||||
if value:
|
||||
metadata[key] = value
|
||||
return metadata
|
||||
|
||||
|
||||
def set_active_task(
|
||||
task_path: str,
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask | None:
|
||||
"""Set the active task in session scope.
|
||||
|
||||
Returns None when no context key is available; callers should surface a
|
||||
user-facing error that explains how to provide session identity.
|
||||
"""
|
||||
canonical = _canonical_task_ref(task_path, repo_root)
|
||||
if canonical is None:
|
||||
return None
|
||||
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if not context_key:
|
||||
return None
|
||||
|
||||
context_path = _context_path(repo_root, context_key)
|
||||
context = _read_json(context_path) or {}
|
||||
context.update(_context_metadata(platform_input, platform, context_key))
|
||||
context["current_task"] = canonical
|
||||
context.setdefault("current_run", None)
|
||||
if not _write_json(context_path, context):
|
||||
return None
|
||||
return ActiveTask(canonical, "session", context_key)
|
||||
|
||||
|
||||
def clear_active_task(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask:
|
||||
"""Clear the active task by deleting the current session context file."""
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if not context_key:
|
||||
return ActiveTask(None, "none")
|
||||
|
||||
previous = resolve_active_task(repo_root, platform_input, platform)
|
||||
context_path = _context_path(repo_root, context_key)
|
||||
if context_path.is_file():
|
||||
_remove_file(context_path)
|
||||
return previous
|
||||
|
||||
|
||||
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
||||
"""Delete all session runtime files that point at a task."""
|
||||
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
|
||||
if not target:
|
||||
return 0
|
||||
|
||||
cleared = 0
|
||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||
if not sessions_dir.is_dir():
|
||||
return cleared
|
||||
|
||||
for session_path in sessions_dir.glob("*.json"):
|
||||
context = _read_json(session_path) or {}
|
||||
current = _string_value(context.get("current_task"))
|
||||
if not current:
|
||||
continue
|
||||
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
|
||||
if current_ref != target:
|
||||
continue
|
||||
if session_path.is_file() and _remove_file(session_path):
|
||||
cleared += 1
|
||||
|
||||
return cleared
|
||||
|
||||
|
||||
def get_current_task_source(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
|
||||
active = resolve_active_task(repo_root, platform_input, platform)
|
||||
return active.source_type, active.context_key, active.task_path
|
||||
Executable
+825
@@ -0,0 +1,825 @@
|
||||
"""
|
||||
CLI Adapter for Multi-Platform Support.
|
||||
|
||||
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||
|
||||
Supported platforms:
|
||||
- claude: Claude Code (default)
|
||||
- opencode: OpenCode
|
||||
- cursor: Cursor IDE
|
||||
- iflow: iFlow CLI
|
||||
- codex: Codex CLI (skills-based)
|
||||
- kilo: Kilo CLI
|
||||
- kiro: Kiro Code (skills-based)
|
||||
- gemini: Gemini CLI
|
||||
- antigravity: Antigravity (workflow-based)
|
||||
- devin: Devin (formerly Windsurf; workflow-based)
|
||||
- qoder: Qoder
|
||||
- codebuddy: CodeBuddy
|
||||
- copilot: GitHub Copilot (VS Code)
|
||||
- droid: Factory Droid (commands-based)
|
||||
- pi: Pi Agent (extension-backed)
|
||||
|
||||
Usage:
|
||||
from common.cli_adapter import CLIAdapter
|
||||
|
||||
adapter = CLIAdapter("opencode")
|
||||
cmd = adapter.build_run_command(
|
||||
agent="dispatch",
|
||||
session_id="abc123",
|
||||
prompt="Start the pipeline"
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
Platform = Literal[
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIAdapter:
|
||||
"""Adapter for different AI coding CLI tools."""
|
||||
|
||||
platform: Platform
|
||||
|
||||
# =========================================================================
|
||||
# Agent Name Mapping
|
||||
# =========================================================================
|
||||
|
||||
# OpenCode has built-in agents that cannot be overridden
|
||||
# See: https://github.com/sst/opencode/issues/4271
|
||||
# Note: Class-level constant, not a dataclass field
|
||||
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
|
||||
"claude": {}, # No mapping needed
|
||||
"opencode": {
|
||||
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
|
||||
},
|
||||
}
|
||||
|
||||
def get_agent_name(self, agent: str) -> str:
|
||||
"""Get platform-specific agent name.
|
||||
|
||||
Args:
|
||||
agent: Original agent name (e.g., 'plan', 'dispatch')
|
||||
|
||||
Returns:
|
||||
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
|
||||
"""
|
||||
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
|
||||
return mapping.get(agent, agent)
|
||||
|
||||
# =========================================================================
|
||||
# Agent Path
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def config_dir_name(self) -> str:
|
||||
"""Get platform-specific config directory name.
|
||||
|
||||
Returns:
|
||||
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi')
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ".opencode"
|
||||
elif self.platform == "cursor":
|
||||
return ".cursor"
|
||||
elif self.platform == "iflow":
|
||||
return ".iflow"
|
||||
elif self.platform == "codex":
|
||||
return ".codex"
|
||||
elif self.platform == "kilo":
|
||||
return ".kilocode"
|
||||
elif self.platform == "kiro":
|
||||
return ".kiro"
|
||||
elif self.platform == "gemini":
|
||||
return ".gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return ".agent"
|
||||
elif self.platform == "devin":
|
||||
return ".devin"
|
||||
elif self.platform == "qoder":
|
||||
return ".qoder"
|
||||
elif self.platform == "codebuddy":
|
||||
return ".codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return ".github/copilot"
|
||||
elif self.platform == "droid":
|
||||
return ".factory"
|
||||
elif self.platform == "pi":
|
||||
return ".pi"
|
||||
else:
|
||||
return ".claude"
|
||||
|
||||
def get_config_dir(self, project_root: Path) -> Path:
|
||||
"""Get platform-specific config directory.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, or .pi)
|
||||
"""
|
||||
return project_root / self.config_dir_name
|
||||
|
||||
def get_agent_path(self, agent: str, project_root: Path) -> Path:
|
||||
"""Get path to agent definition file.
|
||||
|
||||
Args:
|
||||
agent: Agent name (original, before mapping)
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to agent definition file (.md for most platforms, .toml for Codex)
|
||||
"""
|
||||
mapped_name = self.get_agent_name(agent)
|
||||
if self.platform == "codex":
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
|
||||
|
||||
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
|
||||
"""Get path to commands directory or specific command file.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
|
||||
|
||||
Returns:
|
||||
Path to commands directory or file
|
||||
|
||||
Note:
|
||||
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
|
||||
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "pi":
|
||||
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"trellis-{filename}.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
|
||||
if self.platform == "devin":
|
||||
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||
if not parts:
|
||||
return workflow_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
return workflow_dir / f"trellis-{filename}"
|
||||
return workflow_dir / Path(*parts)
|
||||
|
||||
if self.platform in ("antigravity", "kilo"):
|
||||
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||
if not parts:
|
||||
return workflow_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
return workflow_dir / filename
|
||||
return workflow_dir / Path(*parts)
|
||||
|
||||
if self.platform == "copilot":
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"{filename}.prompt.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
|
||||
if not parts:
|
||||
return self.get_config_dir(project_root) / "commands"
|
||||
|
||||
# Cursor uses prefix naming instead of subdirectory
|
||||
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
|
||||
# Convert trellis/<name>.md to trellis-<name>.md
|
||||
filename = parts[-1]
|
||||
return (
|
||||
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
|
||||
)
|
||||
|
||||
return self.get_config_dir(project_root) / "commands" / Path(*parts)
|
||||
|
||||
def get_trellis_command_path(self, name: str) -> str:
|
||||
"""Get relative path to a trellis command file.
|
||||
|
||||
Args:
|
||||
name: Command name without extension (e.g., 'finish-work', 'check')
|
||||
|
||||
Returns:
|
||||
Relative path string for use in JSONL entries
|
||||
|
||||
Note:
|
||||
Cursor: .cursor/commands/trellis-<name>.md
|
||||
Codex: .agents/skills/trellis-<name>/SKILL.md
|
||||
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||
Gemini: .gemini/commands/trellis/<name>.toml
|
||||
Antigravity: .agent/workflows/<name>.md
|
||||
Devin: .devin/workflows/trellis-<name>.md
|
||||
Pi: .pi/prompts/trellis-<name>.md
|
||||
Others: .{platform}/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "cursor":
|
||||
return f".cursor/commands/trellis-{name}.md"
|
||||
elif self.platform == "codex":
|
||||
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
|
||||
# (see that release's manifest for the 60+ rename entries).
|
||||
return f".agents/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "kiro":
|
||||
return f".kiro/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "gemini":
|
||||
return f".gemini/commands/trellis/{name}.toml"
|
||||
elif self.platform == "antigravity":
|
||||
return f".agent/workflows/{name}.md"
|
||||
elif self.platform == "devin":
|
||||
return f".devin/workflows/trellis-{name}.md"
|
||||
elif self.platform == "kilo":
|
||||
return f".kilocode/workflows/{name}.md"
|
||||
elif self.platform == "copilot":
|
||||
return f".github/prompts/{name}.prompt.md"
|
||||
elif self.platform == "droid":
|
||||
return f".factory/commands/trellis/{name}.md"
|
||||
elif self.platform == "pi":
|
||||
return f".pi/prompts/trellis-{name}.md"
|
||||
else:
|
||||
return f"{self.config_dir_name}/commands/trellis/{name}.md"
|
||||
|
||||
# =========================================================================
|
||||
# Environment Variables
|
||||
# =========================================================================
|
||||
|
||||
def get_non_interactive_env(self) -> dict[str, str]:
|
||||
"""Get environment variables for non-interactive mode.
|
||||
|
||||
Returns:
|
||||
Dict of environment variables to set
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return {"OPENCODE_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "iflow":
|
||||
return {"IFLOW_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "codex":
|
||||
return {"CODEX_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "kiro":
|
||||
return {"KIRO_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "gemini":
|
||||
return {} # Gemini CLI doesn't have a non-interactive env var
|
||||
elif self.platform == "antigravity":
|
||||
return {}
|
||||
elif self.platform == "devin":
|
||||
return {}
|
||||
elif self.platform == "qoder":
|
||||
return {}
|
||||
elif self.platform == "codebuddy":
|
||||
return {}
|
||||
elif self.platform == "copilot":
|
||||
return {}
|
||||
elif self.platform == "droid":
|
||||
return {}
|
||||
elif self.platform == "pi":
|
||||
return {}
|
||||
else:
|
||||
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||
|
||||
# =========================================================================
|
||||
# CLI Command Building
|
||||
# =========================================================================
|
||||
|
||||
def build_run_command(
|
||||
self,
|
||||
agent: str,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
skip_permissions: bool = True,
|
||||
verbose: bool = True,
|
||||
json_output: bool = True,
|
||||
) -> list[str]:
|
||||
"""Build CLI command for running an agent.
|
||||
|
||||
Args:
|
||||
agent: Agent name (will be mapped if needed)
|
||||
prompt: Prompt to send to the agent
|
||||
session_id: Optional session ID (Claude Code only for creation)
|
||||
skip_permissions: Whether to skip permission prompts
|
||||
verbose: Whether to enable verbose output
|
||||
json_output: Whether to use JSON output format
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
mapped_agent = self.get_agent_name(agent)
|
||||
|
||||
if self.platform == "opencode":
|
||||
cmd = ["opencode", "run"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
# Note: OpenCode 'run' mode is non-interactive by default
|
||||
# No equivalent to Claude Code's --dangerously-skip-permissions
|
||||
# See: https://github.com/anomalyco/opencode/issues/9070
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--format", "json"])
|
||||
|
||||
if verbose:
|
||||
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
|
||||
|
||||
# Note: OpenCode doesn't support --session-id on creation
|
||||
# Session ID must be extracted from logs after startup
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
elif self.platform == "iflow":
|
||||
cmd = ["iflow", "-y", "-p"]
|
||||
cmd.append(f"${mapped_agent} {prompt}")
|
||||
elif self.platform == "codex":
|
||||
cmd = ["codex", "exec"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "kiro":
|
||||
cmd = ["kiro", "run", prompt]
|
||||
elif self.platform == "gemini":
|
||||
cmd = ["gemini"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
cmd = ["qodercli", "-p", prompt]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI agent run is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
cmd = ["pi", "-p", prompt]
|
||||
|
||||
else: # claude
|
||||
cmd = ["claude", "-p"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
if session_id:
|
||||
cmd.extend(["--session-id", session_id])
|
||||
|
||||
if skip_permissions:
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--output-format", "stream-json"])
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
return cmd
|
||||
|
||||
def build_resume_command(self, session_id: str) -> list[str]:
|
||||
"""Build CLI command for resuming a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume (ignored for iFlow)
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ["opencode", "run", "--session", session_id]
|
||||
elif self.platform == "iflow":
|
||||
# iFlow uses -c to continue most recent conversation
|
||||
# session_id is ignored as iFlow doesn't support session IDs
|
||||
return ["iflow", "-c"]
|
||||
elif self.platform == "codex":
|
||||
return ["codex", "resume", session_id]
|
||||
elif self.platform == "kiro":
|
||||
return ["kiro", "resume", session_id]
|
||||
elif self.platform == "gemini":
|
||||
return ["gemini", "--resume", session_id]
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
return ["qodercli", "--resume", session_id]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI resume is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
return ["pi", "-c", session_id]
|
||||
else:
|
||||
return ["claude", "--resume", session_id]
|
||||
|
||||
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
|
||||
"""Get human-readable resume command string.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume
|
||||
cwd: Optional working directory to cd into
|
||||
|
||||
Returns:
|
||||
Command string for display
|
||||
"""
|
||||
cmd = self.build_resume_command(session_id)
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if cwd:
|
||||
return f"cd {cwd} && {cmd_str}"
|
||||
return cmd_str
|
||||
|
||||
# =========================================================================
|
||||
# Platform Detection Helpers
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def is_opencode(self) -> bool:
|
||||
"""Check if platform is OpenCode."""
|
||||
return self.platform == "opencode"
|
||||
|
||||
@property
|
||||
def is_claude(self) -> bool:
|
||||
"""Check if platform is Claude Code."""
|
||||
return self.platform == "claude"
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
"""Check if platform is Cursor."""
|
||||
return self.platform == "cursor"
|
||||
|
||||
@property
|
||||
def is_iflow(self) -> bool:
|
||||
"""Check if platform is iFlow CLI."""
|
||||
return self.platform == "iflow"
|
||||
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
"""Get CLI executable name.
|
||||
|
||||
Note: Cursor doesn't have a CLI tool, returns None-like value.
|
||||
"""
|
||||
if self.is_opencode:
|
||||
return "opencode"
|
||||
elif self.is_cursor:
|
||||
return "cursor" # Note: Cursor is IDE-only, no CLI
|
||||
elif self.platform == "iflow":
|
||||
return "iflow"
|
||||
elif self.platform == "kiro":
|
||||
return "kiro"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return "agy"
|
||||
elif self.platform == "devin":
|
||||
return "devin"
|
||||
elif self.platform == "qoder":
|
||||
return "qodercli"
|
||||
elif self.platform == "codebuddy":
|
||||
return "codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return "copilot"
|
||||
elif self.platform == "droid":
|
||||
return "droid"
|
||||
elif self.platform == "pi":
|
||||
return "pi"
|
||||
else:
|
||||
return "claude"
|
||||
|
||||
@property
|
||||
def supports_cli_agents(self) -> bool:
|
||||
"""Check if platform supports running agents via CLI.
|
||||
|
||||
Claude Code, OpenCode, iFlow, and Codex support CLI agent execution.
|
||||
Cursor is IDE-only and doesn't support CLI agents.
|
||||
"""
|
||||
return self.platform in ("claude", "opencode", "iflow", "codex", "pi")
|
||||
|
||||
@property
|
||||
def requires_agent_definition_file(self) -> bool:
|
||||
"""Check if platform requires an agent definition file (.md/.toml) to run.
|
||||
|
||||
Claude Code, OpenCode, iFlow: require agent .md files (--agent flag).
|
||||
Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag.
|
||||
"""
|
||||
return self.platform in ("claude", "opencode", "iflow")
|
||||
|
||||
# =========================================================================
|
||||
# Session ID Handling
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def supports_session_id_on_create(self) -> bool:
|
||||
"""Check if platform supports specifying session ID on creation.
|
||||
|
||||
Claude Code: Yes (--session-id)
|
||||
OpenCode: No (auto-generated, extract from logs)
|
||||
iFlow: No (no session ID support)
|
||||
"""
|
||||
return self.platform == "claude"
|
||||
|
||||
def extract_session_id_from_log(self, log_content: str) -> str | None:
|
||||
"""Extract session ID from log output (OpenCode only).
|
||||
|
||||
OpenCode generates session IDs in format: ses_xxx
|
||||
|
||||
Args:
|
||||
log_content: Log file content
|
||||
|
||||
Returns:
|
||||
Session ID if found, None otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
# OpenCode session ID pattern
|
||||
match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Factory Function
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
|
||||
"""Get CLI adapter for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform is not supported
|
||||
|
||||
Note:
|
||||
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||
renamed to Devin) and normalized before validation.
|
||||
"""
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if platform == "windsurf":
|
||||
platform = "devin"
|
||||
if platform not in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')"
|
||||
)
|
||||
|
||||
return CLIAdapter(platform=platform) # type: ignore
|
||||
|
||||
|
||||
_ALL_PLATFORM_CONFIG_DIRS = (
|
||||
".claude",
|
||||
".cursor",
|
||||
".iflow",
|
||||
".opencode",
|
||||
".codex",
|
||||
".kilocode",
|
||||
".kiro",
|
||||
".gemini",
|
||||
".agent",
|
||||
".devin",
|
||||
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||
".qoder",
|
||||
".codebuddy",
|
||||
".github/copilot",
|
||||
".factory",
|
||||
".pi",
|
||||
)
|
||||
"""Platform-specific config directory names used by detect_platform exclusion
|
||||
checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
|
||||
layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
|
||||
agentskills.io standard), not a single-platform signal. Its presence must not
|
||||
block detection of Kiro, Antigravity, Devin, or other platforms."""
|
||||
|
||||
|
||||
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||
"""Check if any platform config dir exists besides those in *exclude*."""
|
||||
return any(
|
||||
(project_root / d).is_dir()
|
||||
for d in _ALL_PLATFORM_CONFIG_DIRS
|
||||
if d not in exclude
|
||||
)
|
||||
|
||||
|
||||
def detect_platform(project_root: Path) -> Platform:
|
||||
"""Auto-detect platform based on existing config directories.
|
||||
|
||||
Detection order:
|
||||
1. TRELLIS_PLATFORM environment variable (if set)
|
||||
2. .opencode directory exists → opencode
|
||||
3. .iflow directory exists → iflow
|
||||
4. .cursor directory exists (without .claude) → cursor
|
||||
5. .codex exists and no other platform dirs → codex
|
||||
6. .kilocode directory exists → kilo
|
||||
7. .kiro/skills exists and no other platform dirs → kiro
|
||||
8. .gemini directory exists → gemini
|
||||
9. .agent/workflows exists and no other platform dirs → antigravity
|
||||
10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||
11. .codebuddy directory exists → codebuddy
|
||||
12. .qoder directory exists → qoder
|
||||
13. .pi directory exists → pi
|
||||
14. Default → claude
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude')
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check environment variable first
|
||||
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if env_platform == "windsurf":
|
||||
env_platform = "devin"
|
||||
if env_platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
):
|
||||
return env_platform # type: ignore
|
||||
|
||||
# Check for .opencode directory (OpenCode-specific)
|
||||
if (project_root / ".opencode").is_dir():
|
||||
return "opencode"
|
||||
|
||||
# Check for .iflow directory (iFlow-specific)
|
||||
if (project_root / ".iflow").is_dir():
|
||||
return "iflow"
|
||||
|
||||
# Check for .cursor directory (Cursor-specific)
|
||||
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
|
||||
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
|
||||
return "cursor"
|
||||
|
||||
# Check for .gemini directory (Gemini CLI-specific)
|
||||
if (project_root / ".gemini").is_dir():
|
||||
return "gemini"
|
||||
|
||||
# Check for .codex directory (Codex-specific)
|
||||
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
|
||||
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".codex", ".agents"}
|
||||
):
|
||||
return "codex"
|
||||
|
||||
# Check for .kilocode directory (Kilo-specific)
|
||||
if (project_root / ".kilocode").is_dir():
|
||||
return "kilo"
|
||||
|
||||
# Check for Kiro skills directory only when no other platform config exists
|
||||
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".kiro"}
|
||||
):
|
||||
return "kiro"
|
||||
|
||||
# Check for Antigravity workflow directory only when no other platform config exists
|
||||
if (
|
||||
project_root / ".agent" / "workflows"
|
||||
).is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".agent", ".gemini"}
|
||||
):
|
||||
return "antigravity"
|
||||
|
||||
# Check for Devin workflow directory only when no other platform config
|
||||
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||
if (
|
||||
(project_root / ".devin" / "workflows").is_dir()
|
||||
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||
) and not _has_other_platform_dir(
|
||||
project_root, {".devin", ".windsurf"}
|
||||
):
|
||||
return "devin"
|
||||
|
||||
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||
if (project_root / ".codebuddy").is_dir():
|
||||
return "codebuddy"
|
||||
|
||||
# Check for .qoder directory (Qoder-specific)
|
||||
if (project_root / ".qoder").is_dir():
|
||||
return "qoder"
|
||||
|
||||
# Check for .github/copilot directory (GitHub Copilot-specific)
|
||||
if (project_root / ".github" / "copilot").is_dir():
|
||||
return "copilot"
|
||||
|
||||
# Check for .factory directory (Factory Droid-specific)
|
||||
if (project_root / ".factory").is_dir():
|
||||
return "droid"
|
||||
|
||||
# Check for .pi directory (Pi Agent-specific)
|
||||
if (project_root / ".pi").is_dir():
|
||||
return "pi"
|
||||
|
||||
# Fallback: checkout only has the Codex shared-skills layer
|
||||
# (.agents/skills/trellis-* dirs) and no explicit platform config dir.
|
||||
# Happens on fresh clones where .codex/ is gitignored/absent but the
|
||||
# shared skills were committed to git. Must guard against the case
|
||||
# where .claude/ or any other platform dir also exists — .agents/skills/
|
||||
# can legitimately coexist with any platform as a shared consumption
|
||||
# layer for Amp/Cline/Warp/etc.
|
||||
agents_skills = project_root / ".agents" / "skills"
|
||||
if agents_skills.is_dir() and not _has_other_platform_dir(
|
||||
project_root, set()
|
||||
):
|
||||
try:
|
||||
for entry in agents_skills.iterdir():
|
||||
if entry.is_dir() and entry.name.startswith("trellis-"):
|
||||
return "codex"
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return "claude"
|
||||
|
||||
|
||||
def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
|
||||
"""Get CLI adapter with auto-detected platform.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance for detected platform
|
||||
"""
|
||||
platform = detect_platform(project_root)
|
||||
return CLIAdapter(platform=platform)
|
||||
Executable
+445
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Trellis configuration reader.
|
||||
|
||||
Reads settings from .trellis/config.yaml with sensible defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# YAML Simple Parser (no dependencies)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _unquote(s: str) -> str:
|
||||
"""Remove exactly one layer of matching surrounding quotes.
|
||||
|
||||
Unlike str.strip('"'), this only removes the outermost pair,
|
||||
preserving any nested quotes inside the value.
|
||||
|
||||
Examples:
|
||||
_unquote('"hello"') -> 'hello'
|
||||
_unquote("'hello'") -> 'hello'
|
||||
_unquote('"echo \\'hi\\'"') -> "echo 'hi'"
|
||||
_unquote('hello') -> 'hello'
|
||||
_unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged)
|
||||
"""
|
||||
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
||||
return s[1:-1]
|
||||
return s
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
|
||||
Mirrors :func:`common.trellis_config._strip_inline_comment` so both
|
||||
parsers handle ``key: value # comment`` identically.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str) -> dict:
|
||||
"""Parse simple YAML with nested dict support (no dependencies).
|
||||
|
||||
Supports:
|
||||
- key: value (string)
|
||||
- key: (followed by list items)
|
||||
- item1
|
||||
- item2
|
||||
- key: (followed by nested dict)
|
||||
nested_key: value
|
||||
nested_key2:
|
||||
- item
|
||||
|
||||
Uses indentation to detect nesting (2+ spaces deeper = child).
|
||||
|
||||
Args:
|
||||
content: YAML content string.
|
||||
|
||||
Returns:
|
||||
Parsed dict (values can be str, list[str], or dict).
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict
|
||||
) -> int:
|
||||
"""Parse a YAML block into target dict, returning next line index."""
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# If dedented past our block, we're done
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
# key: value
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
# key: (no value) — peek ahead to determine list vs nested dict
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
# It's a list
|
||||
current_list = []
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
# It's a nested dict
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
||||
else:
|
||||
# Empty value, same or less indent follows
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
"""Find the next non-empty, non-comment line."""
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
# Defaults
|
||||
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
||||
DEFAULT_MAX_JOURNAL_LINES = 2000
|
||||
DEFAULT_SESSION_AUTO_COMMIT = True
|
||||
|
||||
CONFIG_FILE = "config.yaml"
|
||||
|
||||
|
||||
def _is_true_config_value(value: object) -> bool:
|
||||
"""Return True when a config value represents an enabled flag."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() == "true"
|
||||
return False
|
||||
|
||||
|
||||
def _get_config_path(repo_root: Path | None = None) -> Path:
|
||||
"""Get path to config.yaml."""
|
||||
root = repo_root or get_repo_root()
|
||||
return root / DIR_WORKFLOW / CONFIG_FILE
|
||||
|
||||
|
||||
def _load_config(repo_root: Path | None = None) -> dict:
|
||||
"""Load and parse config.yaml. Returns empty dict on any error."""
|
||||
config_file = _get_config_path(repo_root)
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
return parse_simple_yaml(content)
|
||||
except (OSError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
||||
"""Get the commit message for auto-committing session records."""
|
||||
config = _load_config(repo_root)
|
||||
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
||||
|
||||
|
||||
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
||||
"""Get the maximum lines per journal file."""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_MAX_JOURNAL_LINES
|
||||
|
||||
|
||||
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
||||
"""Whether scripts should auto-stage + auto-commit session/task changes.
|
||||
|
||||
Governs both ``add_session.py:_auto_commit_workspace`` and
|
||||
``task_store.py:_auto_commit_archive``.
|
||||
|
||||
Default: ``True`` (existing behavior — auto-stage + auto-commit).
|
||||
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
|
||||
auto-staging entirely; the journal/archive files are still written to
|
||||
disk, but the user manages ``git add`` / ``git commit`` themselves.
|
||||
|
||||
Accepts native YAML booleans (``true`` / ``false``) and the string
|
||||
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
|
||||
Invalid values fall back to ``True`` with a stderr warning.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
s = str(raw).strip().lower()
|
||||
if s in ("true", "yes", "1", "on"):
|
||||
return True
|
||||
if s in ("false", "no", "0", "off"):
|
||||
return False
|
||||
print(
|
||||
f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return DEFAULT_SESSION_AUTO_COMMIT
|
||||
|
||||
|
||||
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
||||
"""Get hook commands for a lifecycle event.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create", "after_archive").
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
List of shell commands to execute, empty if none configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
hooks = config.get("hooks")
|
||||
if not isinstance(hooks, dict):
|
||||
return []
|
||||
commands = hooks.get(event)
|
||||
if isinstance(commands, list):
|
||||
return [str(c) for c in commands]
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Packages
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
|
||||
"""Get monorepo package declarations.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its config (path, type, etc.),
|
||||
or None if not configured (single-repo mode).
|
||||
|
||||
Example return:
|
||||
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
packages = config.get("packages")
|
||||
if not isinstance(packages, dict):
|
||||
return None
|
||||
# Ensure each value is a dict (filter out scalar entries)
|
||||
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
|
||||
if not filtered:
|
||||
return None
|
||||
return filtered
|
||||
|
||||
|
||||
def get_default_package(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the default package name from config.
|
||||
|
||||
Returns:
|
||||
Package name string, or None if not configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("default_package")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that are git submodules.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for submodule-type packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example return:
|
||||
{"docs-site": "docs-site"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if cfg.get("type") == "submodule"
|
||||
}
|
||||
|
||||
|
||||
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that have their own independent git repository.
|
||||
|
||||
These are sub-directories with their own .git (not submodules),
|
||||
marked with ``git: true`` in config.yaml.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for git-repo packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example config::
|
||||
|
||||
packages:
|
||||
backend:
|
||||
path: iqs
|
||||
git: true
|
||||
|
||||
Example return::
|
||||
|
||||
{"backend": "iqs"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if _is_true_config_value(cfg.get("git"))
|
||||
}
|
||||
|
||||
|
||||
def is_monorepo(repo_root: Path | None = None) -> bool:
|
||||
"""Check if the project is configured as a monorepo (has packages in config)."""
|
||||
return get_packages(repo_root) is not None
|
||||
|
||||
|
||||
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
|
||||
"""Get the spec directory base path relative to .trellis/.
|
||||
|
||||
Single-repo: returns "spec"
|
||||
Monorepo with package: returns "spec/<package>"
|
||||
Monorepo without package: returns "spec" (caller should specify package)
|
||||
"""
|
||||
if package and is_monorepo(repo_root):
|
||||
return f"spec/{package}"
|
||||
return "spec"
|
||||
|
||||
|
||||
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a package name is valid in this project.
|
||||
|
||||
Single-repo (no packages configured): always returns True.
|
||||
Monorepo: returns True only if package exists in config.yaml packages.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return True # Single-repo, no validation needed
|
||||
return package in packages
|
||||
|
||||
|
||||
def resolve_package(
|
||||
task_package: str | None = None,
|
||||
repo_root: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve package from inferred sources with validation.
|
||||
|
||||
Checks in order: task_package → default_package.
|
||||
Invalid inferred values print a warning to stderr and are skipped.
|
||||
|
||||
Returns:
|
||||
Resolved package name, or None if no valid package found.
|
||||
|
||||
Note:
|
||||
CLI --package should be validated separately by the caller
|
||||
(fail-fast with available packages list on error).
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return None # Single-repo, no package needed
|
||||
|
||||
# Try task_package (guard against non-string values from malformed JSON)
|
||||
if task_package and isinstance(task_package, str):
|
||||
if task_package in packages:
|
||||
return task_package
|
||||
print(
|
||||
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Try default_package
|
||||
default = get_default_package(repo_root)
|
||||
if default:
|
||||
if default in packages:
|
||||
return default
|
||||
print(
|
||||
f"Warning: default_package '{default}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
||||
"""Get session.spec_scope configuration.
|
||||
|
||||
Returns:
|
||||
list[str]: Package names to include in spec scanning.
|
||||
str: "active_task" to use current task's package.
|
||||
None: No scope configured (scan all packages).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
session = config.get("session")
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
scope = session.get("spec_scope")
|
||||
if scope is None:
|
||||
return None
|
||||
if isinstance(scope, str):
|
||||
return scope # e.g. "active_task"
|
||||
if isinstance(scope, list):
|
||||
return [str(s) for s in scope]
|
||||
return None
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Developer management utilities.
|
||||
|
||||
Provides:
|
||||
init_developer - Initialize developer
|
||||
ensure_developer - Ensure developer is initialized (exit if not)
|
||||
show_developer_info - Show developer information
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer Initialization
|
||||
# =============================================================================
|
||||
|
||||
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
||||
"""Initialize developer.
|
||||
|
||||
Creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
- Initial journal file and index.md
|
||||
|
||||
Args:
|
||||
name: Developer name.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if not name:
|
||||
print("Error: developer name is required", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||
|
||||
# Create .developer file
|
||||
initialized_at = datetime.now().isoformat()
|
||||
try:
|
||||
dev_file.write_text(
|
||||
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create workspace directory structure
|
||||
try:
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create initial journal file
|
||||
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||
if not journal_file.exists():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
journal_content = f"""# Journal - {name} (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
try:
|
||||
journal_file.write_text(journal_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create index.md with markers for auto-update
|
||||
index_file = workspace_dir / "index.md"
|
||||
if not index_file.exists():
|
||||
index_content = f"""# Workspace Index - {name}
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
"""
|
||||
try:
|
||||
index_file.write_text(index_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print(f"Developer initialized: {name}")
|
||||
print(f" .developer file: {dev_file}")
|
||||
print(f" Workspace dir: {workspace_dir}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||
"""Ensure developer is initialized, exit if not.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if not check_developer(repo_root):
|
||||
print("Error: Developer not initialized.", file=sys.stderr)
|
||||
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||
"""Show developer information.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
if not developer:
|
||||
print("Developer: (not initialized)")
|
||||
else:
|
||||
print(f"Developer: {developer}")
|
||||
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_developer_info()
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Git command execution utility.
|
||||
|
||||
Single source of truth for running git commands across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr).
|
||||
|
||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
||||
consistent output across all platforms (Windows, macOS, Linux).
|
||||
"""
|
||||
try:
|
||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
||||
result = subprocess.run(
|
||||
git_args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Git and Session Context utilities.
|
||||
|
||||
Entry shim — delegates to session_context and packages_context.
|
||||
|
||||
Provides:
|
||||
output_json - Output context in JSON format
|
||||
output_text - Output context in text format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .git import run_git
|
||||
from .session_context import (
|
||||
get_context_json,
|
||||
get_context_text,
|
||||
get_context_record_json,
|
||||
get_context_text_record,
|
||||
output_json,
|
||||
output_text,
|
||||
)
|
||||
from .packages_context import (
|
||||
get_context_packages_text,
|
||||
get_context_packages_json,
|
||||
)
|
||||
from .trellis_config import read_trellis_config
|
||||
from .workflow_phase import (
|
||||
filter_platform,
|
||||
get_phase_index,
|
||||
get_step,
|
||||
resolve_effective_platform,
|
||||
)
|
||||
|
||||
# Backward-compatible alias — external modules import this name
|
||||
_run_git_command = run_git
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
"-j",
|
||||
action="store_true",
|
||||
help="Output in JSON format (works with any --mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
default="default",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "record":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_text_record())
|
||||
elif args.mode == "packages":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_packages_text())
|
||||
elif args.mode == "phase":
|
||||
content = get_step(args.step) if args.step else get_phase_index()
|
||||
if not content.strip():
|
||||
if args.step:
|
||||
parser.exit(2, f"Step not found: {args.step}\n")
|
||||
else:
|
||||
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
||||
if args.platform:
|
||||
effective = resolve_effective_platform(
|
||||
args.platform, read_trellis_config()
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
print(content, end="")
|
||||
else:
|
||||
if args.json:
|
||||
output_json()
|
||||
else:
|
||||
output_text()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
JSON file I/O utilities.
|
||||
|
||||
Provides read_json and write_json as the single source of truth
|
||||
for JSON file operations across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
||||
"""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file with pretty formatting.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Terminal output utilities: colors and structured logging.
|
||||
|
||||
Single source of truth for Colors and log_* functions
|
||||
used across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
CYAN = "\033[0;36m"
|
||||
DIM = "\033[2m"
|
||||
NC = "\033[0m" # No Color / Reset
|
||||
|
||||
|
||||
def colored(text: str, color: str) -> str:
|
||||
"""Apply ANSI color to text."""
|
||||
return f"{color}{text}{Colors.NC}"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
"""Print info-level message with [INFO] prefix."""
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
"""Print success message with [SUCCESS] prefix."""
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
"""Print warning message with [WARN] prefix."""
|
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
"""Print error message with [ERROR] prefix."""
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package discovery and context output.
|
||||
|
||||
Provides:
|
||||
get_packages_info - Get structured package info
|
||||
get_packages_section - Build PACKAGES text section
|
||||
get_context_packages_text - Full packages text output (--mode packages)
|
||||
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
||||
from .paths import (
|
||||
DIR_SPEC,
|
||||
DIR_WORKFLOW,
|
||||
get_current_task,
|
||||
get_repo_root,
|
||||
)
|
||||
from .tasks import load_task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal Helpers
|
||||
# =============================================================================
|
||||
|
||||
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
||||
"""Scan spec directory for available layers (subdirectories).
|
||||
|
||||
For monorepo: scans spec/<package>/
|
||||
For single-repo: scans spec/
|
||||
"""
|
||||
target = spec_dir / package if package else spec_dir
|
||||
if not target.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||
)
|
||||
|
||||
|
||||
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||
"""Get the package field from the active task's task.json."""
|
||||
current = get_current_task(repo_root)
|
||||
if not current:
|
||||
return None
|
||||
ct = load_task(repo_root / current)
|
||||
return ct.package if ct and ct.package else None
|
||||
|
||||
|
||||
def _resolve_scope_set(
|
||||
packages: dict,
|
||||
spec_scope,
|
||||
task_pkg: str | None,
|
||||
default_pkg: str | None,
|
||||
) -> set | None:
|
||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
if spec_scope is None:
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, list):
|
||||
valid = {e for e in spec_scope if e in packages}
|
||||
if valid:
|
||||
return valid
|
||||
# All invalid: fallback
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||
"""Get structured package info for monorepo projects.
|
||||
|
||||
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||
isSubmodule, isGitRepo.
|
||||
Returns empty list for single-repo projects.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if not packages:
|
||||
return []
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
result = []
|
||||
|
||||
for pkg_name, pkg_config in packages.items():
|
||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||
|
||||
result.append({
|
||||
"name": pkg_name,
|
||||
"path": pkg_path,
|
||||
"type": pkg_type,
|
||||
"default": pkg_name == default_pkg,
|
||||
"specLayers": layers,
|
||||
"isSubmodule": pkg_type == "submodule",
|
||||
"isGitRepo": _is_true_config_value(pkg_git),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_packages_section(repo_root: Path) -> str:
|
||||
"""Build the PACKAGES section for text output."""
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("## PACKAGES")
|
||||
|
||||
if not pkg_info:
|
||||
lines.append("(single-repo mode)")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
|
||||
for pkg in pkg_info:
|
||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||
default_tag = " *" if pkg["default"] else ""
|
||||
lines.append(
|
||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||
)
|
||||
|
||||
if default_pkg:
|
||||
lines.append(f"Default package: {default_pkg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||
"""Get packages context as formatted text (for --mode packages)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
lines: list[str] = []
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
lines.append("Single-repo project (no packages configured)")
|
||||
lines.append("")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Resolve scope for annotations
|
||||
packages_dict = get_packages(repo_root) or {}
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||
|
||||
lines.append("## PACKAGES")
|
||||
lines.append("")
|
||||
for pkg in pkg_info:
|
||||
default_tag = " (default)" if pkg["default"] else ""
|
||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||
|
||||
# Scope annotation
|
||||
scope_tag = ""
|
||||
if scope_set is not None and pkg["name"] not in scope_set:
|
||||
scope_tag = " (out of scope)"
|
||||
|
||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||
lines.append(f"Path: {pkg['path']}")
|
||||
if pkg["specLayers"]:
|
||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||
for layer in pkg["specLayers"]:
|
||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||
else:
|
||||
lines.append("Spec: not configured")
|
||||
lines.append("")
|
||||
|
||||
# Also show shared guides
|
||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||
if guides_dir.is_dir():
|
||||
lines.append("### Shared Guides (always included)")
|
||||
lines.append("Path: .trellis/spec/guides/index.md")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
return {
|
||||
"mode": "single-repo",
|
||||
"specLayers": layers,
|
||||
}
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
|
||||
return {
|
||||
"mode": "monorepo",
|
||||
"packages": pkg_info,
|
||||
"defaultPackage": default_pkg,
|
||||
"specScope": spec_scope,
|
||||
"activeTaskPackage": task_pkg,
|
||||
}
|
||||
Executable
+447
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common path utilities for Trellis workflow.
|
||||
|
||||
Provides:
|
||||
get_repo_root - Get repository root directory
|
||||
get_developer - Get developer name
|
||||
get_workspace_dir - Get developer workspace directory
|
||||
get_tasks_dir - Get tasks directory
|
||||
get_active_journal_file - Get current journal file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Constants (change here to rename directories)
|
||||
# =============================================================================
|
||||
|
||||
# Directory names
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_WORKSPACE = "workspace"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_ARCHIVE = "archive"
|
||||
DIR_SPEC = "spec"
|
||||
DIR_SCRIPTS = "scripts"
|
||||
|
||||
# File names
|
||||
FILE_DEVELOPER = ".developer"
|
||||
FILE_CURRENT_TASK = ".current-task"
|
||||
FILE_TASK_JSON = "task.json"
|
||||
FILE_JOURNAL_PREFIX = "journal-"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Repository Root
|
||||
# =============================================================================
|
||||
|
||||
def get_repo_root(start_path: Path | None = None) -> Path:
|
||||
"""Find the nearest directory containing .trellis/ folder.
|
||||
|
||||
This handles nested git repos correctly (e.g., test project inside another repo).
|
||||
|
||||
Args:
|
||||
start_path: Starting directory to search from. Defaults to current directory.
|
||||
|
||||
Returns:
|
||||
Path to repository root, or current directory if no .trellis/ found.
|
||||
"""
|
||||
current = (start_path or Path.cwd()).resolve()
|
||||
|
||||
while current != current.parent:
|
||||
if (current / DIR_WORKFLOW).is_dir():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
# Fallback to current directory if no .trellis/ found
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer
|
||||
# =============================================================================
|
||||
|
||||
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||
"""Get developer name from .developer file.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Developer name or None if not initialized.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
|
||||
if not dev_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = dev_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("name="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_developer(repo_root: Path | None = None) -> bool:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if developer is initialized.
|
||||
"""
|
||||
return get_developer(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tasks Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
||||
"""Get tasks directory path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to tasks directory.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workspace Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get developer workspace directory.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to workspace directory or None if developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Journal File
|
||||
# =============================================================================
|
||||
|
||||
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get the current active journal file.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to active journal file or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
workspace_dir = get_workspace_dir(repo_root)
|
||||
if workspace_dir is None or not workspace_dir.is_dir():
|
||||
return None
|
||||
|
||||
latest: Path | None = None
|
||||
highest = 0
|
||||
|
||||
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Extract number from filename
|
||||
name = f.stem # e.g., "journal-1"
|
||||
match = re.search(r"(\d+)$", name)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if num > highest:
|
||||
highest = num
|
||||
latest = f
|
||||
|
||||
return latest
|
||||
|
||||
|
||||
def count_lines(file_path: Path) -> int:
|
||||
"""Count lines in a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file.
|
||||
|
||||
Returns:
|
||||
Number of lines, or 0 if file doesn't exist.
|
||||
"""
|
||||
if not file_path.is_file():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return len(file_path.read_text(encoding="utf-8").splitlines())
|
||||
except (OSError, IOError):
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Current Task Management
|
||||
# =============================================================================
|
||||
|
||||
def normalize_task_ref(task_ref: str) -> str:
|
||||
"""Normalize a task ref for stable runtime storage.
|
||||
|
||||
Stored refs should prefer repo-relative POSIX paths like
|
||||
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
||||
unless they can later be converted back to repo-relative form by callers.
|
||||
"""
|
||||
normalized = task_ref.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||
return f"{DIR_WORKFLOW}/{normalized}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Resolve a task ref to an absolute task directory path."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return path_obj
|
||||
|
||||
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
return repo_root / path_obj
|
||||
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
|
||||
def get_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Get current task directory path (relative to repo_root).
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Relative path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import resolve_active_task
|
||||
|
||||
return resolve_active_task(repo_root, platform_input, platform).task_path
|
||||
|
||||
|
||||
def get_current_task_abs(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> Path | None:
|
||||
"""Get current task directory absolute path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Absolute path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
relative = get_current_task(repo_root, platform_input, platform)
|
||||
if relative:
|
||||
return resolve_task_ref(relative, repo_root)
|
||||
return None
|
||||
|
||||
|
||||
def get_current_task_source(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import get_current_task_source as _get_source
|
||||
|
||||
return _get_source(repo_root, platform_input, platform)
|
||||
|
||||
|
||||
def set_current_task(
|
||||
task_path: str,
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Set current task in session scope.
|
||||
|
||||
Args:
|
||||
task_path: Task directory path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import set_active_task
|
||||
|
||||
return set_active_task(
|
||||
task_path,
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
) is not None
|
||||
|
||||
|
||||
def clear_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Clear current task in session scope.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import clear_active_task
|
||||
|
||||
clear_active_task(
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def has_current_task(repo_root: Path | None = None) -> bool:
|
||||
"""Check if has current task.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if current task is set.
|
||||
"""
|
||||
return get_current_task(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task ID Generation
|
||||
# =============================================================================
|
||||
|
||||
def generate_task_date_prefix() -> str:
|
||||
"""Generate task ID based on date (MM-DD format).
|
||||
|
||||
Returns:
|
||||
Date prefix string (e.g., "01-21").
|
||||
"""
|
||||
return datetime.now().strftime("%m-%d")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Package Paths
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
||||
"""Get the spec directory path.
|
||||
|
||||
Single-repo: .trellis/spec
|
||||
Monorepo with package: .trellis/spec/<package>
|
||||
|
||||
Uses lazy import to avoid circular dependency with config.py.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_spec_base
|
||||
|
||||
base = get_spec_base(package, repo_root)
|
||||
return repo_root / DIR_WORKFLOW / base
|
||||
|
||||
|
||||
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Get a package's source directory absolute path from config.
|
||||
|
||||
Returns:
|
||||
Absolute path to the package directory, or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_packages
|
||||
|
||||
packages = get_packages(repo_root)
|
||||
if not packages or package not in packages:
|
||||
return None
|
||||
|
||||
info = packages[package]
|
||||
if isinstance(info, dict):
|
||||
rel_path = info.get("path", package)
|
||||
else:
|
||||
rel_path = str(info)
|
||||
|
||||
return repo_root / rel_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
print(f"Repository root: {repo}")
|
||||
print(f"Developer: {get_developer(repo)}")
|
||||
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||
print(f"Current task: {get_current_task(repo)}")
|
||||
Executable
+315
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Safe git-add helpers for Trellis-owned paths.
|
||||
|
||||
Why this module exists
|
||||
----------------------
|
||||
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
||||
template / personal habit). When `add_session.py` and `task.py archive` ran
|
||||
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
||||
agent driving the workflow "fixed" it by retrying with
|
||||
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
||||
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
||||
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
||||
|
||||
Design
|
||||
------
|
||||
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
||||
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
||||
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
||||
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||
or set ``session_auto_commit: false`` and manage git themselves.
|
||||
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||
so any AI rereading the log doesn't reinvent the bug.
|
||||
|
||||
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||
user had gitignored violates user intent even when the path list is narrow.
|
||||
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||
``-f`` is gone too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .git import run_git
|
||||
from .paths import (
|
||||
DIR_ARCHIVE,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_developer,
|
||||
)
|
||||
|
||||
|
||||
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||
# warning to the user can show concrete subpaths to ignore individually
|
||||
# instead of ignoring the whole `.trellis/` tree.
|
||||
TRELLIS_IGNORED_SUBPATHS = (
|
||||
".trellis/.backup-*",
|
||||
".trellis/worktrees/",
|
||||
".trellis/.template-hashes.json",
|
||||
".trellis/.runtime/",
|
||||
".trellis/.cache/",
|
||||
)
|
||||
|
||||
|
||||
def safe_trellis_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||
|
||||
Only includes paths that exist on disk so callers don't pass non-existent
|
||||
arguments to git. The caller is responsible for `git diff --cached`
|
||||
checking afterwards.
|
||||
|
||||
Included:
|
||||
- .trellis/workspace/<developer>/journal-*.md
|
||||
- .trellis/workspace/<developer>/index.md
|
||||
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||
``task_name`` is passed; plus its archive location if the task
|
||||
already lives under archive/)
|
||||
|
||||
Excluded (intentionally — these must not be staged):
|
||||
- .trellis/.backup-*, .trellis/worktrees/,
|
||||
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||
|
||||
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||
passed, the task segment stages ONLY that task directory — it never walks
|
||||
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||
parallel-window task dirs from being bundled into the session auto-commit.
|
||||
|
||||
Backwards-compat: with no ``task_name``, the function walks every active
|
||||
task directory (+ the archive subtree) the old wide way. New callers
|
||||
should always pass ``task_name``.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
|
||||
# Workspace journal files + index.md
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
if ws.is_dir():
|
||||
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
|
||||
if f.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
|
||||
)
|
||||
index_md = ws / "index.md"
|
||||
if index_md.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||
)
|
||||
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — ONLY the current task directory (active or archived).
|
||||
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||
# leak into the session auto-commit.
|
||||
active_task = tasks_dir / task_name
|
||||
if active_task.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||
if archived_task.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||
)
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||
# directory and not the archive root, plus the whole archive subtree.
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def safe_archive_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
modified_children: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return paths to stage after `task.py archive`.
|
||||
|
||||
Scoped to ONLY the paths the archive operation actually touched:
|
||||
|
||||
- the archive subtree (where the freshly-moved task lives)
|
||||
- the source task directory (for source-side deletes; caller pairs
|
||||
this with `git rm --cached` since `git add` won't stage deletes
|
||||
for a path that no longer exists in the working tree)
|
||||
- any child task directories whose `task.json` was edited to drop
|
||||
the archived parent (parent-children relationship update)
|
||||
|
||||
This narrow scope avoids "scope creep" — dirty changes in OTHER
|
||||
active task dirs (parallel-window edits) are NOT bundled into the
|
||||
archive commit. Callers handle each kind of change in its own
|
||||
commit boundary.
|
||||
|
||||
Backwards-compat: with no arguments, the function walks the whole
|
||||
`.trellis/tasks/` subtree the old way (active tasks + archive). New
|
||||
callers should always pass `task_name`.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — only paths that still exist on disk (so
|
||||
# `git add` doesn't choke on the moved-away source). The caller
|
||||
# handles the source-side deletes via `git rm --cached`
|
||||
# explicitly.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
|
||||
)
|
||||
for child_name in modified_children or []:
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): preserve old behavior so callers
|
||||
# that have not been updated keep working.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
return paths
|
||||
|
||||
|
||||
def _stderr_indicates_ignored(stderr: str) -> bool:
|
||||
"""git add error indicates the path is excluded by .gitignore."""
|
||||
if not stderr:
|
||||
return False
|
||||
lowered = stderr.lower()
|
||||
return "ignored by" in lowered
|
||||
|
||||
|
||||
def safe_git_add(
|
||||
paths: list[str], repo_root: Path
|
||||
) -> tuple[bool, bool, str]:
|
||||
"""Run `git add` on specific paths; never retry with -f.
|
||||
|
||||
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
|
||||
kept for signature compatibility with the 0.5.10 implementation but is
|
||||
always ``False`` — we never auto-force.
|
||||
|
||||
Behavior:
|
||||
- No paths passed → success, no force, empty stderr.
|
||||
- Plain ``git add -- <paths>`` succeeds → return success.
|
||||
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||
the stderr. Callers should inspect the stderr (see
|
||||
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||
"""
|
||||
if not paths:
|
||||
return True, False, ""
|
||||
|
||||
rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
|
||||
if rc == 0:
|
||||
return True, False, ""
|
||||
return False, False, err
|
||||
|
||||
|
||||
def print_gitignore_warning(paths: list[str]) -> None:
|
||||
"""Explain to the user (and any AI reading the log) what to do.
|
||||
|
||||
CRITICAL: includes the negative example
|
||||
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||
known to invent that command, which fans out to ignored caches/backups.
|
||||
"""
|
||||
print(
|
||||
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] git was not touched.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if paths:
|
||||
for p in paths:
|
||||
print(f"[WARN] {p}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/<task-dir>/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/archive/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||
print(f"[WARN] {sub}", file=sys.stderr)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/config.yaml:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] session_auto_commit: false",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] and runtime caches that should never be committed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
Executable
+821
@@ -0,0 +1,821 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Session context generation (default + record modes).
|
||||
|
||||
Provides:
|
||||
get_context_json - JSON output for default mode
|
||||
get_context_text - Text output for default mode
|
||||
get_context_record_json - JSON for record mode
|
||||
get_context_text_record - Text for record mode
|
||||
output_json - Print JSON
|
||||
output_text - Print text
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .active_task import resolve_context_key
|
||||
from .config import get_git_packages
|
||||
from .git import run_git
|
||||
from .packages_context import get_packages_section
|
||||
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
||||
from .paths import (
|
||||
DIR_SCRIPTS,
|
||||
DIR_SPEC,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
count_lines,
|
||||
get_active_journal_file,
|
||||
get_current_task,
|
||||
get_current_task_source,
|
||||
get_developer,
|
||||
get_repo_root,
|
||||
get_tasks_dir,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
_PACKAGE_NAME = "@mindfoldhq/trellis"
|
||||
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
||||
_VERSION_RE = re.compile(
|
||||
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
||||
)
|
||||
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
||||
_POLYREPO_IGNORED_DIRS = {
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"bin",
|
||||
"obj",
|
||||
"vendor",
|
||||
"coverage",
|
||||
"tmp",
|
||||
"__pycache__",
|
||||
}
|
||||
_POLYREPO_SCAN_MAX_DEPTH = 2
|
||||
|
||||
|
||||
def _is_git_worktree(path: Path) -> bool:
|
||||
"""Return True when path is inside a Git worktree."""
|
||||
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
||||
return rc == 0 and out.strip().lower() == "true"
|
||||
|
||||
|
||||
def _parse_recent_commits(log_output: str) -> list[dict]:
|
||||
"""Parse `git log --oneline` output into structured commit entries."""
|
||||
commits = []
|
||||
for line in log_output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) >= 2:
|
||||
commits.append({"hash": parts[0], "message": parts[1]})
|
||||
elif len(parts) == 1:
|
||||
commits.append({"hash": parts[0], "message": ""})
|
||||
return commits
|
||||
|
||||
|
||||
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
||||
"""Collect Git status for one known repository directory."""
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
||||
changes = len([l for l in status_out.splitlines() if l.strip()])
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"path": rel_path,
|
||||
"branch": branch,
|
||||
"isClean": changes == 0,
|
||||
"uncommittedChanges": changes,
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _collect_root_git_info(repo_root: Path) -> dict:
|
||||
"""Collect root Git info without pretending a non-Git root is clean."""
|
||||
if not _is_git_worktree(repo_root):
|
||||
return {
|
||||
"isRepo": False,
|
||||
"branch": "",
|
||||
"isClean": False,
|
||||
"uncommittedChanges": 0,
|
||||
"recentCommits": [],
|
||||
}
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
||||
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
||||
|
||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
||||
|
||||
return {
|
||||
"isRepo": True,
|
||||
"branch": branch,
|
||||
"isClean": len(status_lines) == 0,
|
||||
"uncommittedChanges": len(status_lines),
|
||||
"statusShort": short_out.splitlines(),
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
||||
found: list[str] = []
|
||||
|
||||
def is_candidate_dir(path: Path) -> bool:
|
||||
name = path.name
|
||||
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
||||
|
||||
def scan(rel_dir: Path, depth: int) -> None:
|
||||
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
||||
return
|
||||
abs_dir = repo_root / rel_dir
|
||||
try:
|
||||
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir() or not is_candidate_dir(child):
|
||||
continue
|
||||
|
||||
child_rel = (
|
||||
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
||||
)
|
||||
if (child / ".git").exists():
|
||||
found.append(child_rel.as_posix())
|
||||
continue
|
||||
scan(child_rel, depth + 1)
|
||||
|
||||
scan(Path("."), 0)
|
||||
if len(found) < 2:
|
||||
return []
|
||||
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
||||
|
||||
|
||||
def _collect_package_git_info(
|
||||
repo_root: Path,
|
||||
discover_unconfigured: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Collect Git status for independent package repositories.
|
||||
|
||||
Packages marked with ``git: true`` in config.yaml are authoritative.
|
||||
When the Trellis root is not a Git repo and no configured package repos are
|
||||
available, optionally fall back to the bounded polyrepo child scan.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, path, branch, isClean,
|
||||
uncommittedChanges, recentCommits.
|
||||
Empty list if no git-repo packages are configured.
|
||||
"""
|
||||
git_pkgs = get_git_packages(repo_root)
|
||||
result = []
|
||||
for pkg_name, pkg_path in git_pkgs.items():
|
||||
pkg_dir = repo_root / pkg_path
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
||||
if info is not None:
|
||||
result.append(info)
|
||||
|
||||
if result or not discover_unconfigured:
|
||||
return result
|
||||
|
||||
discovered = []
|
||||
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
||||
if info is not None:
|
||||
discovered.append(info)
|
||||
return discovered
|
||||
|
||||
|
||||
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
||||
"""Append root Git status without misleading non-Git roots."""
|
||||
lines.append("## GIT STATUS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append("Root is not a Git repository.")
|
||||
lines.append("Run Git commands from the package repository paths listed below.")
|
||||
else:
|
||||
lines.append(f"Branch: {root_git_info['branch']}")
|
||||
if root_git_info["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {root_git_info['uncommittedChanges']} "
|
||||
"uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Changes:")
|
||||
for line in root_git_info.get("statusShort", [])[:10]:
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## RECENT COMMITS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append(
|
||||
"Root has no Git commit history because it is not a Git repository."
|
||||
)
|
||||
elif root_git_info["recentCommits"]:
|
||||
for commit in root_git_info["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
||||
"""Append Git status and recent commits for package repositories."""
|
||||
for pkg in package_git_info:
|
||||
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
||||
lines.append(f"Branch: {pkg['branch']}")
|
||||
if pkg["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
||||
if pkg["recentCommits"]:
|
||||
for commit in pkg["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _read_project_version(repo_root: Path) -> str | None:
|
||||
try:
|
||||
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
except OSError:
|
||||
return None
|
||||
return version or None
|
||||
|
||||
|
||||
def _fetch_trellis_version_output() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["trellis", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = f"{result.stdout}\n{result.stderr}".strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _extract_available_update_version(output: str) -> str | None:
|
||||
update_match = re.search(
|
||||
r"Trellis update available:\s*"
|
||||
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
||||
output,
|
||||
)
|
||||
if update_match:
|
||||
return update_match.group("latest").strip()
|
||||
candidates = _VERSION_TOKEN_RE.findall(output)
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def _resolve_available_update_version() -> str | None:
|
||||
output = _fetch_trellis_version_output()
|
||||
if not output:
|
||||
return None
|
||||
return _extract_available_update_version(output)
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
||||
match = _VERSION_RE.match(version)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
||||
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
||||
return numbers, prerelease_parts
|
||||
|
||||
|
||||
def _compare_prerelease(
|
||||
left: tuple[str, ...] | None,
|
||||
right: tuple[str, ...] | None,
|
||||
) -> int:
|
||||
if left is None and right is None:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
|
||||
for left_part, right_part in zip(left, right):
|
||||
if left_part == right_part:
|
||||
continue
|
||||
left_numeric = left_part.isdigit()
|
||||
right_numeric = right_part.isdigit()
|
||||
if left_numeric and right_numeric:
|
||||
left_int = int(left_part)
|
||||
right_int = int(right_part)
|
||||
return (left_int > right_int) - (left_int < right_int)
|
||||
if left_numeric:
|
||||
return -1
|
||||
if right_numeric:
|
||||
return 1
|
||||
return (left_part > right_part) - (left_part < right_part)
|
||||
|
||||
return (len(left) > len(right)) - (len(left) < len(right))
|
||||
|
||||
|
||||
def _compare_versions(left: str, right: str) -> int | None:
|
||||
parsed_left = _parse_version(left)
|
||||
parsed_right = _parse_version(right)
|
||||
if parsed_left is None or parsed_right is None:
|
||||
return None
|
||||
|
||||
left_numbers, left_prerelease = parsed_left
|
||||
right_numbers, right_prerelease = parsed_right
|
||||
if left_numbers != right_numbers:
|
||||
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
||||
if not safe_key:
|
||||
safe_key = "session"
|
||||
return (
|
||||
repo_root
|
||||
/ DIR_WORKFLOW
|
||||
/ ".runtime"
|
||||
/ f"update-check-{safe_key[:160]}.marker"
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text("checked\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
if marker_path.exists():
|
||||
return None
|
||||
|
||||
current_version = _read_project_version(repo_root)
|
||||
if not current_version:
|
||||
return None
|
||||
|
||||
latest_version = _resolve_available_update_version()
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||
"run trellis upgrade"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get context as a dictionary.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Context dictionary.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
|
||||
journal_lines = 0
|
||||
journal_relative = ""
|
||||
if journal_file and developer:
|
||||
journal_lines = count_lines(journal_file)
|
||||
journal_relative = (
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# Tasks
|
||||
tasks = [
|
||||
{
|
||||
"dir": t.dir_name,
|
||||
"name": t.name,
|
||||
"status": t.status,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
for t in iter_active_tasks(tasks_dir)
|
||||
]
|
||||
|
||||
# Package git repos (independent sub-repositories)
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"tasks": {
|
||||
"active": tasks,
|
||||
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
||||
},
|
||||
"journal": {
|
||||
"file": journal_relative,
|
||||
"lines": journal_lines,
|
||||
"nearLimit": journal_lines > 1800,
|
||||
},
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def output_json(repo_root: Path | None = None) -> None:
|
||||
"""Output context in JSON format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
context = get_context_json(repo_root)
|
||||
print(json.dumps(context, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_text(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Formatted text output.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
# Developer section
|
||||
lines.append("## DEVELOPER")
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Name: {developer}")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# Current task
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
current_task_dir = repo_root / current_task
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
|
||||
ct = load_task(current_task_dir)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||
if ct.description:
|
||||
lines.append(f"Description: {ct.description}")
|
||||
|
||||
# Check for prd.md
|
||||
prd_file = current_task_dir / "prd.md"
|
||||
if prd_file.is_file():
|
||||
lines.append("")
|
||||
lines.append("[!] This task has prd.md - read it for task details")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
# Active tasks
|
||||
lines.append("## ACTIVE TASKS")
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
task_count = 0
|
||||
|
||||
# Collect all task data for hierarchy display
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||
nonlocal task_count
|
||||
t = all_tasks[name]
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
prefix = " " * indent
|
||||
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||
task_count += 1
|
||||
for child in t.children:
|
||||
if child in all_tasks:
|
||||
_print_task_tree(child, indent + 1)
|
||||
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task_tree(dir_name)
|
||||
|
||||
if task_count == 0:
|
||||
lines.append("(no active tasks)")
|
||||
lines.append(f"Total: {task_count} active task(s)")
|
||||
lines.append("")
|
||||
|
||||
# My tasks
|
||||
lines.append("## MY TASKS (Assigned to me)")
|
||||
my_task_count = 0
|
||||
|
||||
for t in all_tasks.values():
|
||||
if t.assignee == developer and t.status != "done":
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
# Journal file
|
||||
lines.append("## JOURNAL FILE")
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
if journal_file:
|
||||
journal_lines = count_lines(journal_file)
|
||||
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
lines.append(f"Active file: {relative}")
|
||||
lines.append(f"Line count: {journal_lines} / 2000")
|
||||
if journal_lines > 1800:
|
||||
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||
else:
|
||||
lines.append("No journal file found")
|
||||
lines.append("")
|
||||
|
||||
# Packages
|
||||
packages_text = get_packages_section(repo_root)
|
||||
if packages_text:
|
||||
lines.append(packages_text)
|
||||
lines.append("")
|
||||
|
||||
# Paths
|
||||
lines.append("## PATHS")
|
||||
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Record Mode
|
||||
# =============================================================================
|
||||
|
||||
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get record-mode context as a dictionary.
|
||||
|
||||
Focused on: my active tasks, git status, current task.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# My tasks (single pass — collect statuses and filter by assignee)
|
||||
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||
|
||||
my_tasks = []
|
||||
for t in all_tasks_list:
|
||||
if t.assignee == developer:
|
||||
done = sum(
|
||||
1 for c in t.children
|
||||
if all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
my_tasks.append({
|
||||
"dir": t.dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"children": list(t.children),
|
||||
"childrenDone": done,
|
||||
"parent": t.parent,
|
||||
"meta": t.meta,
|
||||
})
|
||||
|
||||
# Current task
|
||||
current_task_info = None
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
current_task_info = {
|
||||
"path": current_task,
|
||||
"name": ct.name,
|
||||
"status": ct.status,
|
||||
"source": source_type,
|
||||
"contextKey": context_key,
|
||||
}
|
||||
|
||||
# Package git repos
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"myTasks": my_tasks,
|
||||
"currentTask": current_task_info,
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text for record-session mode.
|
||||
|
||||
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# MY ACTIVE TASKS — first and prominent
|
||||
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||
lines.append("")
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
my_task_count = 0
|
||||
|
||||
# Single pass — collect all tasks and filter by assignee
|
||||
all_statuses = get_all_statuses(tasks_dir)
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.assignee == developer:
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no active tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# CURRENT TASK
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def output_text(repo_root: Path | None = None) -> None:
|
||||
"""Output context in text format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
print(get_context_text(repo_root))
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task JSONL context management.
|
||||
|
||||
Provides:
|
||||
cmd_add_context - Add entry to JSONL context file
|
||||
cmd_validate - Validate JSONL context files
|
||||
cmd_list_context - List JSONL context entries
|
||||
|
||||
Note:
|
||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
||||
are now seeded at ``task.py create`` time with a self-describing
|
||||
``_example`` line; the AI agent curates real entries during planning when
|
||||
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
|
||||
current planning artifact contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .log import Colors, colored
|
||||
from .paths import get_repo_root
|
||||
from .task_utils import resolve_task_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: add-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_add_context(args: argparse.Namespace) -> int:
|
||||
"""Add entry to JSONL context file."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
jsonl_name = args.file
|
||||
path = args.path
|
||||
reason = args.reason or "Added manually"
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Support shorthand
|
||||
if not jsonl_name.endswith(".jsonl"):
|
||||
jsonl_name = f"{jsonl_name}.jsonl"
|
||||
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
full_path = repo_root / path
|
||||
|
||||
entry_type = "file"
|
||||
if full_path.is_dir():
|
||||
entry_type = "directory"
|
||||
if not path.endswith("/"):
|
||||
path = f"{path}/"
|
||||
elif not full_path.is_file():
|
||||
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Check if already exists
|
||||
if jsonl_file.is_file():
|
||||
content = jsonl_file.read_text(encoding="utf-8")
|
||||
if f'"{path}"' in content:
|
||||
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Add entry
|
||||
entry: dict
|
||||
if entry_type == "directory":
|
||||
entry = {"file": path, "type": "directory", "reason": reason}
|
||||
else:
|
||||
entry = {"file": path, "reason": reason}
|
||||
|
||||
with jsonl_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: validate
|
||||
# =============================================================================
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
"""Validate JSONL context files."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
||||
print(f"Target dir: {target_dir}")
|
||||
print()
|
||||
|
||||
total_errors = 0
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
errors = _validate_jsonl(jsonl_file, repo_root)
|
||||
total_errors += errors
|
||||
|
||||
print()
|
||||
if total_errors == 0:
|
||||
print(colored("✓ All validations passed", Colors.GREEN))
|
||||
return 0
|
||||
else:
|
||||
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
|
||||
skipped silently; they are self-describing comments, not real entries.
|
||||
"""
|
||||
file_name = jsonl_file.name
|
||||
errors = 0
|
||||
|
||||
if not jsonl_file.is_file():
|
||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||
return 0
|
||||
|
||||
line_num = 0
|
||||
real_entries = 0
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
line_num += 1
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
entry_type = data.get("type", "file")
|
||||
|
||||
if not file_path:
|
||||
# Seed / comment row — skip silently
|
||||
continue
|
||||
|
||||
real_entries += 1
|
||||
full_path = repo_root / file_path
|
||||
if entry_type == "directory":
|
||||
if not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
else:
|
||||
if not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
|
||||
if errors == 0:
|
||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||
else:
|
||||
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_context(args: argparse.Namespace) -> int:
|
||||
"""List JSONL context entries."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Context Files ===", Colors.BLUE))
|
||||
print()
|
||||
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
if not jsonl_file.is_file():
|
||||
continue
|
||||
|
||||
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||||
|
||||
count = 0
|
||||
seed_only = True
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
if not file_path:
|
||||
# Seed / comment row — don't count as a real entry
|
||||
continue
|
||||
seed_only = False
|
||||
|
||||
count += 1
|
||||
entry_type = data.get("type", "file")
|
||||
reason = data.get("reason", "-")
|
||||
|
||||
if entry_type == "directory":
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
||||
else:
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
||||
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
||||
|
||||
if seed_only:
|
||||
print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}")
|
||||
|
||||
print()
|
||||
|
||||
return 0
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task queue utility functions.
|
||||
|
||||
Provides:
|
||||
list_tasks_by_status - List tasks by status
|
||||
list_pending_tasks - List tasks with pending status
|
||||
list_tasks_by_assignee - List tasks by assignee
|
||||
list_my_tasks - List tasks assigned to current developer
|
||||
get_task_stats - Get P0/P1/P2/P3 counts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
)
|
||||
from .tasks import iter_active_tasks
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helper
|
||||
# =============================================================================
|
||||
|
||||
def _task_to_dict(t) -> dict:
|
||||
"""Convert TaskInfo to the dict format callers expect."""
|
||||
return {
|
||||
"priority": t.priority,
|
||||
"id": t.raw.get("id", ""),
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"assignee": t.assignee or "-",
|
||||
"dir": t.dir_name,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def list_tasks_by_status(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks by status.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts with keys: priority, id, title, status, assignee.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
||||
"""List pending tasks.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
return list_tasks_by_status("planning", repo_root)
|
||||
|
||||
|
||||
def list_tasks_by_assignee(
|
||||
assignee: str,
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to a specific developer.
|
||||
|
||||
Args:
|
||||
assignee: Developer name.
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if (t.assignee or "-") != assignee:
|
||||
continue
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_my_tasks(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to current developer.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
|
||||
Raises:
|
||||
ValueError: If developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
raise ValueError("Developer not set")
|
||||
|
||||
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
||||
|
||||
|
||||
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
||||
"""Get task statistics.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with keys: P0, P1, P2, P3, Total.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.priority in stats:
|
||||
stats[t.priority] += 1
|
||||
stats["Total"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def format_task_stats(stats: dict[str, int]) -> str:
|
||||
"""Format task stats as string.
|
||||
|
||||
Args:
|
||||
stats: Stats dict from get_task_stats.
|
||||
|
||||
Returns:
|
||||
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
||||
"""
|
||||
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
stats = get_task_stats()
|
||||
print(format_task_stats(stats))
|
||||
print()
|
||||
print("Pending tasks:")
|
||||
for task in list_pending_tasks():
|
||||
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
||||
Executable
+746
@@ -0,0 +1,746 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task CRUD operations.
|
||||
|
||||
Provides:
|
||||
ensure_tasks_dir - Ensure tasks directory exists
|
||||
cmd_create - Create a new task
|
||||
cmd_archive - Archive completed task
|
||||
cmd_set_branch - Set git branch for task
|
||||
cmd_set_base_branch - Set PR target branch
|
||||
cmd_set_scope - Set scope for PR title
|
||||
cmd_add_subtask - Link child task to parent
|
||||
cmd_remove_subtask - Unlink child task from parent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import (
|
||||
get_packages,
|
||||
get_session_auto_commit,
|
||||
is_monorepo,
|
||||
resolve_package,
|
||||
validate_package,
|
||||
)
|
||||
from .git import run_git
|
||||
from .io import read_json, write_json
|
||||
from .log import Colors, colored
|
||||
from .paths import (
|
||||
DIR_ARCHIVE,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
FILE_TASK_JSON,
|
||||
generate_task_date_prefix,
|
||||
get_developer,
|
||||
get_repo_root,
|
||||
get_tasks_dir,
|
||||
)
|
||||
from .safe_commit import (
|
||||
print_gitignore_warning,
|
||||
safe_archive_paths_to_add,
|
||||
safe_git_add,
|
||||
)
|
||||
from .task_utils import (
|
||||
archive_task_complete,
|
||||
find_task_by_name,
|
||||
resolve_task_dir,
|
||||
run_task_hooks,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Convert title to slug (only works with ASCII)."""
|
||||
result = title.lower()
|
||||
result = re.sub(r"[^a-z0-9]", "-", result)
|
||||
result = re.sub(r"-+", "-", result)
|
||||
result = result.strip("-")
|
||||
return result
|
||||
|
||||
|
||||
def ensure_tasks_dir(repo_root: Path) -> Path:
|
||||
"""Ensure tasks directory exists."""
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
archive_dir = tasks_dir / "archive"
|
||||
|
||||
if not tasks_dir.exists():
|
||||
tasks_dir.mkdir(parents=True)
|
||||
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
||||
|
||||
if not archive_dir.exists():
|
||||
archive_dir.mkdir(parents=True)
|
||||
|
||||
return tasks_dir
|
||||
|
||||
|
||||
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
||||
"""Find an archived task directory with the exact active-task dir name."""
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if not archive_dir.is_dir():
|
||||
return None
|
||||
|
||||
for month_dir in sorted(archive_dir.iterdir()):
|
||||
if not month_dir.is_dir():
|
||||
continue
|
||||
candidate = month_dir / dir_name
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
||||
"""Format a path relative to the repo root when possible."""
|
||||
try:
|
||||
return path.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sub-agent platform detection + JSONL seeding
|
||||
# =============================================================================
|
||||
|
||||
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
||||
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
||||
# platforms listed in workflow.md's "agent-capable" Skill Routing block
|
||||
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
|
||||
# Devin are NOT in this list: they do not consume JSONL.
|
||||
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
||||
".claude",
|
||||
".cursor",
|
||||
".codex",
|
||||
".kiro",
|
||||
".gemini",
|
||||
".opencode",
|
||||
".qoder",
|
||||
".codebuddy",
|
||||
".factory", # Factory Droid
|
||||
".github/copilot",
|
||||
".pi", # Pi Agent
|
||||
)
|
||||
|
||||
_SEED_EXAMPLE = (
|
||||
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
|
||||
"Put spec/research files only — no code paths. "
|
||||
"Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. "
|
||||
"Delete this line once real entries are added."
|
||||
)
|
||||
|
||||
|
||||
def _has_subagent_platform(repo_root: Path) -> bool:
|
||||
"""Return True if any sub-agent-capable platform is configured.
|
||||
|
||||
Detected by probing well-known config directories at the repo root. Used
|
||||
only to decide whether ``task.py create`` should seed empty
|
||||
``implement.jsonl`` / ``check.jsonl`` files.
|
||||
"""
|
||||
for config_dir in _SUBAGENT_CONFIG_DIRS:
|
||||
if (repo_root / config_dir).is_dir():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _write_seed_jsonl(path: Path) -> None:
|
||||
"""Write a one-line seed JSONL file with a self-describing ``_example``.
|
||||
|
||||
The seed row has no ``file`` field, so downstream consumers (hooks +
|
||||
preludes) that iterate entries via ``item.get("file")`` naturally skip
|
||||
it. The row exists purely as an in-file prompt for the AI curator.
|
||||
"""
|
||||
seed = {"_example": _SEED_EXAMPLE}
|
||||
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _default_prd_content(title: str, description: str | None = None) -> str:
|
||||
"""Return the default PRD skeleton created with every task."""
|
||||
goal = (description or "").strip() or "TBD."
|
||||
heading = title.strip() or "Untitled task"
|
||||
return f"""# {heading}
|
||||
|
||||
## Goal
|
||||
|
||||
{goal}
|
||||
|
||||
## Requirements
|
||||
|
||||
- TBD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TBD
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||
- Lightweight tasks can remain PRD-only.
|
||||
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: create
|
||||
# =============================================================================
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> int:
|
||||
"""Create a new task."""
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if not args.title:
|
||||
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate --package (CLI source: fail-fast)
|
||||
package: str | None = getattr(args, "package", None)
|
||||
if not is_monorepo(repo_root):
|
||||
# Single-repo: ignore --package, no package prefix
|
||||
if package:
|
||||
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
||||
package = None
|
||||
elif package:
|
||||
if not validate_package(package, repo_root):
|
||||
packages = get_packages(repo_root)
|
||||
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||||
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
# Inferred: default_package → None (no task.json yet for create)
|
||||
package = resolve_package(repo_root=repo_root)
|
||||
|
||||
# Default assignee to current developer
|
||||
assignee = args.assignee
|
||||
if not assignee:
|
||||
assignee = get_developer(repo_root)
|
||||
if not assignee:
|
||||
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ensure_tasks_dir(repo_root)
|
||||
|
||||
# Get current developer as creator
|
||||
creator = get_developer(repo_root) or assignee
|
||||
|
||||
# Generate slug if not provided
|
||||
slug = args.slug or _slugify(args.title)
|
||||
if not slug:
|
||||
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Create task directory with MM-DD-slug format
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
date_prefix = generate_task_date_prefix()
|
||||
dir_name = f"{date_prefix}-{slug}"
|
||||
task_dir = tasks_dir / dir_name
|
||||
task_json_path = task_dir / FILE_TASK_JSON
|
||||
|
||||
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
|
||||
if archived_task_dir:
|
||||
print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
|
||||
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
||||
print("Use a new slug if you intend to create a new task.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if task_dir.exists():
|
||||
print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
|
||||
else:
|
||||
task_dir.mkdir(parents=True)
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Record current branch as base_branch (PR target)
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
current_branch = branch_out.strip() or "main"
|
||||
|
||||
task_data = {
|
||||
"id": slug,
|
||||
"name": slug,
|
||||
"title": args.title,
|
||||
"description": args.description or "",
|
||||
"status": "planning",
|
||||
"dev_type": None,
|
||||
"scope": None,
|
||||
"package": package,
|
||||
"priority": args.priority,
|
||||
"creator": creator,
|
||||
"assignee": assignee,
|
||||
"createdAt": today,
|
||||
"completedAt": None,
|
||||
"branch": None,
|
||||
"base_branch": current_branch,
|
||||
"worktree_path": None,
|
||||
"commit": None,
|
||||
"pr_url": None,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": None,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
write_json(task_json_path, task_data)
|
||||
|
||||
prd_path = task_dir / "prd.md"
|
||||
if not prd_path.exists():
|
||||
prd_path.write_text(
|
||||
_default_prd_content(args.title, args.description),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
||||
# Agent curates real entries during planning when the task needs them.
|
||||
# Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
|
||||
# load specs via the trellis-before-dev skill instead of JSONL.
|
||||
seeded_jsonl = False
|
||||
if _has_subagent_platform(repo_root):
|
||||
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
||||
jsonl_path = task_dir / jsonl_name
|
||||
if not jsonl_path.exists():
|
||||
_write_seed_jsonl(jsonl_path)
|
||||
seeded_jsonl = True
|
||||
|
||||
# Handle --parent: establish bidirectional link
|
||||
if args.parent:
|
||||
parent_dir = resolve_task_dir(args.parent, repo_root)
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||
if not parent_json_path.is_file():
|
||||
print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
|
||||
else:
|
||||
parent_data = read_json(parent_json_path)
|
||||
if parent_data:
|
||||
# Add child to parent's children list
|
||||
parent_children = parent_data.get("children", [])
|
||||
if dir_name not in parent_children:
|
||||
parent_children.append(dir_name)
|
||||
parent_data["children"] = parent_children
|
||||
write_json(parent_json_path, parent_data)
|
||||
|
||||
# Set parent in child's task.json
|
||||
task_data["parent"] = parent_dir.name
|
||||
write_json(task_json_path, task_data)
|
||||
|
||||
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||
|
||||
# Auto-activate the new task so the per-turn breadcrumb fires planning
|
||||
# state. Best-effort: gracefully degrade if no session identity (CLI run
|
||||
# outside an AI session) — the task is still created, the user can run
|
||||
# task.py start later. Pointer is session-scoped so this never affects
|
||||
# other AI sessions.
|
||||
try:
|
||||
from .active_task import resolve_context_key, set_active_task
|
||||
if resolve_context_key():
|
||||
try:
|
||||
rel_dir = task_dir.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
rel_dir = str(task_dir)
|
||||
set_active_task(rel_dir, repo_root)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
||||
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
||||
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
||||
print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
|
||||
if seeded_jsonl:
|
||||
print(
|
||||
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
||||
# Output relative path for script chaining
|
||||
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
||||
|
||||
run_task_hooks("after_create", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: archive
|
||||
# =============================================================================
|
||||
|
||||
def cmd_archive(args: argparse.Namespace) -> int:
|
||||
"""Archive completed task."""
|
||||
repo_root = get_repo_root()
|
||||
task_name = args.name
|
||||
|
||||
if not task_name:
|
||||
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||
task_dir = resolve_task_dir(task_name, repo_root)
|
||||
|
||||
if not task_dir or not task_dir.is_dir():
|
||||
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
||||
print("Active tasks:", file=sys.stderr)
|
||||
# Import lazily to avoid circular dependency
|
||||
from .tasks import iter_active_tasks
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
print(f" - {t.dir_name}/", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dir_name = task_dir.name
|
||||
task_json_path = task_dir / FILE_TASK_JSON
|
||||
|
||||
# Update status before archiving
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
# Names of child task dirs whose task.json gets modified below; passed
|
||||
# into safe_archive_paths_to_add so they're staged in this commit.
|
||||
modified_children: list[str] = []
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data:
|
||||
data["status"] = "completed"
|
||||
data["completedAt"] = today
|
||||
write_json(task_json_path, data)
|
||||
|
||||
# Handle subtask relationships on archive.
|
||||
# Keep this task in its parent's children list so progress
|
||||
# counters (children_progress) stay consistent — children
|
||||
# missing from the active set are treated as completed.
|
||||
task_children = data.get("children", [])
|
||||
|
||||
# If this is a parent, clear parent field in all children
|
||||
if task_children:
|
||||
for child_name in task_children:
|
||||
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
||||
if child_dir_path:
|
||||
child_json = child_dir_path / FILE_TASK_JSON
|
||||
if child_json.is_file():
|
||||
child_data = read_json(child_json)
|
||||
if child_data:
|
||||
child_data["parent"] = None
|
||||
write_json(child_json, child_data)
|
||||
modified_children.append(child_dir_path.name)
|
||||
|
||||
# Clear any session that still points at this task before the path moves.
|
||||
from .active_task import clear_task_from_sessions
|
||||
clear_task_from_sessions(str(task_dir), repo_root)
|
||||
|
||||
# Archive
|
||||
result = archive_task_complete(task_dir, repo_root)
|
||||
if "archived_to" in result:
|
||||
archive_dest = Path(result["archived_to"])
|
||||
year_month = archive_dest.parent.name
|
||||
print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
|
||||
|
||||
# Auto-commit unless --no-commit
|
||||
if not getattr(args, "no_commit", False):
|
||||
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
||||
print(
|
||||
colored(
|
||||
"Archive moved on disk, but git auto-commit did not complete. "
|
||||
"Resolve `git status` before continuing.",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Return the archive path
|
||||
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
|
||||
|
||||
# Run hooks with the archived path
|
||||
archived_json = archive_dest / FILE_TASK_JSON
|
||||
run_task_hooks("after_archive", archived_json, repo_root)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _auto_commit_archive(
|
||||
task_name: str,
|
||||
repo_root: Path,
|
||||
modified_children: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""Stage Trellis-owned task paths and commit after archive.
|
||||
|
||||
Scoped narrowly to the archived task's source + destination paths
|
||||
plus any child task dirs whose ``task.json`` was edited (parent →
|
||||
children relationship update). Dirty changes in OTHER active task
|
||||
dirs are NOT bundled into the archive commit.
|
||||
|
||||
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
||||
retry with ``git add -f``. The warning explicitly forbids
|
||||
``git add -f .trellis/`` (which would fan out to caches/backups)
|
||||
and points users at ``session_auto_commit: false``.
|
||||
|
||||
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
||||
set to ``false``, this function returns immediately without
|
||||
touching git (the archive directory move on disk is unaffected).
|
||||
"""
|
||||
if not get_session_auto_commit(repo_root):
|
||||
print(
|
||||
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
||||
rc, tracked_out, _ = run_git(
|
||||
["ls-files", "--", source_rel],
|
||||
cwd=repo_root,
|
||||
)
|
||||
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
||||
|
||||
paths = safe_archive_paths_to_add(
|
||||
repo_root, task_name=task_name, modified_children=modified_children
|
||||
)
|
||||
if not paths:
|
||||
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||
return True
|
||||
|
||||
success, _, err = safe_git_add(paths, repo_root)
|
||||
if not success:
|
||||
if err and "ignored by" in err.lower():
|
||||
print_gitignore_warning(paths)
|
||||
else:
|
||||
print(
|
||||
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return not source_was_tracked
|
||||
|
||||
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
||||
# `git add` (no -A) which only stages additions/modifications. The
|
||||
# source task directory was moved away by `shutil.move`, so its files
|
||||
# need an explicit `git rm --cached` to stage the deletions in this
|
||||
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
||||
# against HEAD until something later picks them up.
|
||||
#
|
||||
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
||||
# (e.g. archiving a task that lived only in working tree).
|
||||
run_git(
|
||||
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
||||
cwd=repo_root,
|
||||
)
|
||||
|
||||
rc, _, _ = run_git(
|
||||
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
||||
cwd=repo_root,
|
||||
)
|
||||
if rc == 0:
|
||||
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||
return True
|
||||
|
||||
commit_msg = f"chore(task): archive {task_name}"
|
||||
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||
if rc == 0:
|
||||
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
||||
return not source_was_tracked
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: add-subtask
|
||||
# =============================================================================
|
||||
|
||||
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
||||
"""Link a child task to a parent task."""
|
||||
repo_root = get_repo_root()
|
||||
|
||||
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||
child_json_path = child_dir / FILE_TASK_JSON
|
||||
|
||||
if not parent_json_path.is_file():
|
||||
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not child_json_path.is_file():
|
||||
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parent_data = read_json(parent_json_path)
|
||||
child_data = read_json(child_json_path)
|
||||
|
||||
if not parent_data or not child_data:
|
||||
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Check if child already has a parent
|
||||
existing_parent = child_data.get("parent")
|
||||
if existing_parent:
|
||||
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Add child to parent's children list
|
||||
parent_children = parent_data.get("children", [])
|
||||
child_dir_name = child_dir.name
|
||||
if child_dir_name not in parent_children:
|
||||
parent_children.append(child_dir_name)
|
||||
parent_data["children"] = parent_children
|
||||
|
||||
# Set parent in child's task.json
|
||||
child_data["parent"] = parent_dir.name
|
||||
|
||||
# Write both
|
||||
write_json(parent_json_path, parent_data)
|
||||
write_json(child_json_path, child_data)
|
||||
|
||||
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: remove-subtask
|
||||
# =============================================================================
|
||||
|
||||
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
||||
"""Unlink a child task from a parent task."""
|
||||
repo_root = get_repo_root()
|
||||
|
||||
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
||||
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
||||
|
||||
parent_json_path = parent_dir / FILE_TASK_JSON
|
||||
child_json_path = child_dir / FILE_TASK_JSON
|
||||
|
||||
if not parent_json_path.is_file():
|
||||
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not child_json_path.is_file():
|
||||
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parent_data = read_json(parent_json_path)
|
||||
child_data = read_json(child_json_path)
|
||||
|
||||
if not parent_data or not child_data:
|
||||
print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Remove child from parent's children list
|
||||
parent_children = parent_data.get("children", [])
|
||||
child_dir_name = child_dir.name
|
||||
if child_dir_name in parent_children:
|
||||
parent_children.remove(child_dir_name)
|
||||
parent_data["children"] = parent_children
|
||||
|
||||
# Clear parent in child's task.json
|
||||
child_data["parent"] = None
|
||||
|
||||
# Write both
|
||||
write_json(parent_json_path, parent_data)
|
||||
write_json(child_json_path, child_data)
|
||||
|
||||
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-branch
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_branch(args: argparse.Namespace) -> int:
|
||||
"""Set git branch for task."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
branch = args.branch
|
||||
|
||||
if not branch:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["branch"] = branch
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-base-branch
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
||||
"""Set the base branch (PR target) for task."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
base_branch = args.base_branch
|
||||
|
||||
if not base_branch:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
|
||||
print("Example: python3 task.py set-base-branch <dir> develop")
|
||||
print()
|
||||
print("This sets the target branch for PR (the branch your feature will merge into).")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["base_branch"] = base_branch
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
||||
print(f" PR will target: {base_branch}")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-scope
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_scope(args: argparse.Namespace) -> int:
|
||||
"""Set scope for PR title."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
scope = args.scope
|
||||
|
||||
if not scope:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python3 task.py set-scope <task-dir> <scope>")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
data["scope"] = scope
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||||
return 0
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task utility functions.
|
||||
|
||||
Provides:
|
||||
is_safe_task_path - Validate task path is safe to operate on
|
||||
find_task_by_name - Find task directory by name
|
||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||
archive_task_dir - Archive task to monthly directory
|
||||
run_task_hooks - Run lifecycle hooks for task events
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import get_repo_root, get_tasks_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Safety
|
||||
# =============================================================================
|
||||
|
||||
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a relative task path is safe to operate on.
|
||||
|
||||
Args:
|
||||
task_path: Task path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if safe, False if dangerous.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = task_path.replace("\\", "/")
|
||||
|
||||
# Check empty or null
|
||||
if not normalized or normalized == "null":
|
||||
print("Error: empty or null task path", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject absolute paths
|
||||
if Path(task_path).is_absolute():
|
||||
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Reject ".", "..", paths starting with "./" or "../", or containing ".."
|
||||
if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
|
||||
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Final check: ensure resolved path is not the repo root
|
||||
abs_path = repo_root / Path(normalized)
|
||||
if abs_path.exists():
|
||||
try:
|
||||
resolved = abs_path.resolve()
|
||||
root_resolved = repo_root.resolve()
|
||||
if resolved == root_resolved:
|
||||
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
|
||||
return False
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Lookup
|
||||
# =============================================================================
|
||||
|
||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||
"""Find task directory by name (exact or suffix match).
|
||||
|
||||
Args:
|
||||
task_name: Task name to find.
|
||||
tasks_dir: Tasks directory path.
|
||||
|
||||
Returns:
|
||||
Absolute path to task directory, or None if not found.
|
||||
"""
|
||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
exact_match = tasks_dir / task_name
|
||||
if exact_match.is_dir():
|
||||
return exact_match
|
||||
|
||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
||||
for d in tasks_dir.iterdir():
|
||||
if d.is_dir() and d.name.endswith(f"-{task_name}"):
|
||||
return d
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Archive Operations
|
||||
# =============================================================================
|
||||
|
||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to archived directory, or None on error.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Get tasks directory (parent of the task)
|
||||
tasks_dir = task_dir_abs.parent
|
||||
archive_dir = tasks_dir / "archive"
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
month_dir = archive_dir / year_month
|
||||
|
||||
# Create archive directory
|
||||
try:
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Move task to archive
|
||||
task_name = task_dir_abs.name
|
||||
dest = month_dir / task_name
|
||||
|
||||
try:
|
||||
shutil.move(str(task_dir_abs), str(dest))
|
||||
except (OSError, IOError, shutil.Error) as e:
|
||||
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def archive_task_complete(
|
||||
task_dir_abs: Path,
|
||||
repo_root: Path | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Complete archive workflow: archive directory.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with archive result info.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
archive_dest = archive_task_dir(task_dir_abs, repo_root)
|
||||
if archive_dest:
|
||||
return {"archived_to": str(archive_dest)}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Directory Resolution
|
||||
# =============================================================================
|
||||
|
||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
|
||||
"""Resolve task directory to absolute path.
|
||||
|
||||
Supports:
|
||||
- Absolute path: /path/to/task
|
||||
- Relative path: .trellis/tasks/01-31-my-task
|
||||
- Task name: my-task (uses find_task_by_name for lookup)
|
||||
|
||||
Args:
|
||||
target_dir: Task directory specification.
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
Resolved absolute path.
|
||||
"""
|
||||
if not target_dir:
|
||||
return Path()
|
||||
|
||||
normalized = target_dir.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
# Absolute path
|
||||
if Path(target_dir).is_absolute():
|
||||
return Path(target_dir)
|
||||
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
if "/" in normalized or normalized.startswith(".trellis"):
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
# Task name - try to find in tasks directory
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
found = find_task_by_name(target_dir, tasks_dir)
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Fallback to treating as relative path
|
||||
return repo_root / Path(normalized)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle Hooks
|
||||
# =============================================================================
|
||||
|
||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||
"""Run lifecycle hooks for a task event.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create").
|
||||
task_json_path: Absolute path to the task's task.json.
|
||||
repo_root: Repository root for cwd and config lookup.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .config import get_hooks
|
||||
from .log import Colors, colored
|
||||
|
||||
commands = get_hooks(event, repo_root)
|
||||
if not commands:
|
||||
return
|
||||
|
||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||
|
||||
for cmd in commands:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if result.stderr.strip():
|
||||
print(f" {result.stderr.strip()}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(
|
||||
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
tasks = get_tasks_dir(repo)
|
||||
|
||||
print(f"Tasks dir: {tasks}")
|
||||
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
|
||||
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Task data access layer.
|
||||
|
||||
Single source of truth for loading and iterating task directories.
|
||||
Replaces scattered task.json parsing across 9+ files.
|
||||
|
||||
Provides:
|
||||
load_task — Load a single task by directory path
|
||||
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
||||
get_all_statuses — Get {dir_name: status} map for children progress
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from .io import read_json
|
||||
from .paths import FILE_TASK_JSON
|
||||
from .types import TaskInfo
|
||||
|
||||
|
||||
def load_task(task_dir: Path) -> TaskInfo | None:
|
||||
"""Load task from a directory containing task.json.
|
||||
|
||||
Args:
|
||||
task_dir: Absolute path to the task directory.
|
||||
|
||||
Returns:
|
||||
TaskInfo if task.json exists and is valid, None otherwise.
|
||||
"""
|
||||
task_json = task_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
return None
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return TaskInfo(
|
||||
dir_name=task_dir.name,
|
||||
directory=task_dir,
|
||||
title=data.get("title") or data.get("name") or "unknown",
|
||||
status=data.get("status", "unknown"),
|
||||
assignee=data.get("assignee", ""),
|
||||
priority=data.get("priority", "P2"),
|
||||
children=tuple(data.get("children", [])),
|
||||
parent=data.get("parent"),
|
||||
package=data.get("package"),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
||||
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
||||
|
||||
Skips the "archive" directory and directories without valid task.json.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Yields:
|
||||
TaskInfo for each valid task.
|
||||
"""
|
||||
if not tasks_dir.is_dir():
|
||||
return
|
||||
|
||||
for d in sorted(tasks_dir.iterdir()):
|
||||
if not d.is_dir() or d.name == "archive":
|
||||
continue
|
||||
info = load_task(d)
|
||||
if info is not None:
|
||||
yield info
|
||||
|
||||
|
||||
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
||||
"""Get a {dir_name: status} mapping for all active tasks.
|
||||
|
||||
Useful for computing children progress without loading full TaskInfo.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Returns:
|
||||
Dict mapping directory names to status strings.
|
||||
"""
|
||||
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
||||
|
||||
|
||||
def children_progress(
|
||||
children: tuple[str, ...] | list[str],
|
||||
all_statuses: dict[str, str],
|
||||
) -> str:
|
||||
"""Format children progress string like " [2/3 done]".
|
||||
|
||||
Args:
|
||||
children: List of child directory names.
|
||||
all_statuses: Status map from get_all_statuses().
|
||||
|
||||
Returns:
|
||||
Formatted string, or "" if no children.
|
||||
"""
|
||||
if not children:
|
||||
return ""
|
||||
# A child missing from active statuses has been archived (cmd_archive
|
||||
# sets status=completed before moving the dir). Count it as done so
|
||||
# parent progress doesn't regress when children are archived.
|
||||
done = sum(
|
||||
1 for c in children
|
||||
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
return f" [{done}/{len(children)} done]"
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone reader for .trellis/config.yaml.
|
||||
|
||||
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
|
||||
can read configuration without importing the full task/repo helpers. Returns
|
||||
an empty dict on missing/malformed files so callers stay simple.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
CONFIG_REL_PATH = ".trellis/config.yaml"
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict
|
||||
) -> int:
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
current_list = []
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
|
||||
else:
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str) -> dict:
|
||||
"""Parse a small subset of YAML. See common.config for full doc."""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result)
|
||||
return result
|
||||
|
||||
|
||||
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
||||
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
||||
root = repo_root or Path.cwd()
|
||||
config_file = root / CONFIG_REL_PATH
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_simple_yaml(content)
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Core type definitions for Trellis task data.
|
||||
|
||||
Provides:
|
||||
TaskData — TypedDict for task.json shape (read-path type hints only)
|
||||
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
||||
AgentRecord — TypedDict for registry.json agent entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# task.json shape (TypedDict — used only for read-path type hints)
|
||||
# =============================================================================
|
||||
|
||||
class TaskData(TypedDict, total=False):
|
||||
"""Shape of task.json on disk.
|
||||
|
||||
Used only for type annotations when reading task.json.
|
||||
Writes must use the original dict to avoid losing unknown fields.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
dev_type: str
|
||||
scope: str | None
|
||||
package: str | None
|
||||
priority: str
|
||||
creator: str
|
||||
assignee: str
|
||||
createdAt: str
|
||||
completedAt: str | None
|
||||
branch: str | None
|
||||
base_branch: str | None
|
||||
worktree_path: str | None
|
||||
commit: str | None
|
||||
pr_url: str | None
|
||||
subtasks: list[str]
|
||||
children: list[str]
|
||||
parent: str | None
|
||||
relatedFiles: list[str]
|
||||
notes: str
|
||||
meta: dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Loaded task object (frozen dataclass — the public API type)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskInfo:
|
||||
"""Immutable view of a loaded task.
|
||||
|
||||
Created by load_task() / iter_active_tasks().
|
||||
Contains the commonly accessed fields; the original dict
|
||||
is preserved in `raw` for write-back and uncommon field access.
|
||||
"""
|
||||
|
||||
dir_name: str
|
||||
directory: Path
|
||||
title: str
|
||||
status: str
|
||||
assignee: str
|
||||
priority: str
|
||||
children: tuple[str, ...]
|
||||
parent: str | None
|
||||
package: str | None
|
||||
raw: dict # original dict — use for writes and uncommon fields
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Task name (id or name field)."""
|
||||
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.raw.get("description", "")
|
||||
|
||||
@property
|
||||
def branch(self) -> str | None:
|
||||
return self.raw.get("branch")
|
||||
|
||||
@property
|
||||
def meta(self) -> dict:
|
||||
return self.raw.get("meta", {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# registry.json agent entry
|
||||
# =============================================================================
|
||||
|
||||
class AgentRecord(TypedDict, total=False):
|
||||
"""Shape of an agent entry in registry.json."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
task_dir: str
|
||||
worktree_path: str
|
||||
branch: str
|
||||
platform: str
|
||||
started_at: str
|
||||
status: str
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Workflow Phase Extraction.
|
||||
|
||||
Extracts step-level content from .trellis/workflow.md and optionally filters
|
||||
platform-specific blocks.
|
||||
|
||||
Platform marker syntax in workflow.md:
|
||||
|
||||
[Claude Code, Cursor, ...]
|
||||
agent-capable content
|
||||
[/Claude Code, Cursor, ...]
|
||||
|
||||
Provides:
|
||||
get_phase_index - Extract the Phase Index section (no --step)
|
||||
get_step - Extract a single step (#### X.X) section
|
||||
filter_platform - Strip platform blocks that don't include the given name
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
|
||||
|
||||
def _workflow_md_path():
|
||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||
|
||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
||||
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
||||
|
||||
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
||||
_PHASE_INDEX_HEADING = "## Phase Index"
|
||||
|
||||
|
||||
def _read_workflow() -> str:
|
||||
path = _workflow_md_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"workflow.md not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||
"""Parse a platform marker line.
|
||||
|
||||
Returns:
|
||||
(is_closing, [platform_names]) if line is a marker, else None.
|
||||
"""
|
||||
m = _MARKER_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
is_closing = m.group(1) == "/"
|
||||
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
||||
return is_closing, names
|
||||
|
||||
|
||||
def get_phase_index() -> str:
|
||||
"""Return the compact Phase Index summary from workflow.md.
|
||||
|
||||
SessionStart and no-step phase context use this small summary as their
|
||||
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||
consumed by the per-turn hook, so they're stripped from this output.
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||
start = i
|
||||
continue
|
||||
if start is not None and stripped == "## Phase 1: Plan":
|
||||
end = i
|
||||
break
|
||||
|
||||
if start is None:
|
||||
return ""
|
||||
if end is None:
|
||||
end = len(lines)
|
||||
|
||||
section = "\n".join(lines[start:end]).rstrip()
|
||||
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
|
||||
# they're injected separately by inject-workflow-state.py per-turn.
|
||||
import re as _re
|
||||
tag_re = _re.compile(
|
||||
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
|
||||
_re.DOTALL,
|
||||
)
|
||||
return tag_re.sub("", section).rstrip() + "\n"
|
||||
|
||||
|
||||
def get_step(step_id: str) -> str:
|
||||
"""Return the `#### X.X` section matching step_id (header + body).
|
||||
|
||||
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
m = _STEP_HEADING_RE.match(line)
|
||||
if m and m.group(1) == step_id:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return ""
|
||||
|
||||
end: int = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
line = lines[j]
|
||||
if line.startswith("#### "):
|
||||
end = j
|
||||
break
|
||||
if line.startswith("## "):
|
||||
end = j
|
||||
break
|
||||
# Horizontal rule at column 0
|
||||
if line.strip() == "---":
|
||||
end = j
|
||||
break
|
||||
|
||||
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||
|
||||
|
||||
def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
|
||||
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
for name in block_names:
|
||||
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
if needle == hay:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||
|
||||
When ``--platform codex`` is passed, return ``"codex-inline"`` (default)
|
||||
or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``.
|
||||
``filter_platform`` then surfaces blocks whose marker lists include the
|
||||
namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
|
||||
Antigravity, Devin]``).
|
||||
|
||||
Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"``
|
||||
isolation and can't inherit the parent session's task context — inline
|
||||
keeps the main agent in charge so context isn't lost. Invalid / missing
|
||||
values also fall back to inline.
|
||||
|
||||
Other platforms are returned unchanged.
|
||||
"""
|
||||
if platform == "codex":
|
||||
mode = "inline"
|
||||
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||
if isinstance(codex_cfg, dict):
|
||||
cfg_mode = codex_cfg.get("dispatch_mode")
|
||||
if cfg_mode in ("inline", "sub-agent"):
|
||||
mode = cfg_mode
|
||||
return f"codex-{mode}"
|
||||
return platform
|
||||
|
||||
|
||||
def filter_platform(content: str, platform: str) -> str:
|
||||
"""Keep lines outside any `[...]` block + lines inside blocks that include platform.
|
||||
|
||||
Marker lines themselves are dropped from the output.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
out: list[str] = []
|
||||
|
||||
in_block = False
|
||||
keep_block = False
|
||||
|
||||
for line in lines:
|
||||
marker = _parse_marker(line)
|
||||
if marker is not None:
|
||||
is_closing, names = marker
|
||||
if not is_closing:
|
||||
in_block = True
|
||||
keep_block = _platform_matches(platform, names)
|
||||
else:
|
||||
in_block = False
|
||||
keep_block = False
|
||||
continue # drop the marker line itself
|
||||
|
||||
if in_block:
|
||||
if keep_block:
|
||||
out.append(line)
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
# Collapse runs of 3+ blank lines that may arise from dropped markers
|
||||
collapsed: list[str] = []
|
||||
blank_run = 0
|
||||
for line in out:
|
||||
if line.strip() == "":
|
||||
blank_run += 1
|
||||
if blank_run <= 2:
|
||||
collapsed.append(line)
|
||||
else:
|
||||
blank_run = 0
|
||||
collapsed.append(line)
|
||||
|
||||
return "\n".join(collapsed).rstrip() + "\n"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get Session Context for AI Agent.
|
||||
|
||||
Usage:
|
||||
python3 get_context.py Output context in text format
|
||||
python3 get_context.py --json Output context in JSON format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from common.git_context import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get current developer name.
|
||||
|
||||
This is a wrapper that uses common/paths.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import get_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
developer = get_developer()
|
||||
if developer:
|
||||
print(developer)
|
||||
else:
|
||||
print("Developer not initialized", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Linear sync hook for Trellis task lifecycle.
|
||||
|
||||
Syncs task events to Linear via the `linearis` CLI.
|
||||
|
||||
Usage (called automatically by task.py hooks):
|
||||
python3 .trellis/scripts/hooks/linear_sync.py create
|
||||
python3 .trellis/scripts/hooks/linear_sync.py start
|
||||
python3 .trellis/scripts/hooks/linear_sync.py archive
|
||||
|
||||
Manual usage:
|
||||
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
|
||||
|
||||
Environment:
|
||||
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
|
||||
|
||||
Configuration:
|
||||
.trellis/hooks.local.json - Local config (gitignored), example:
|
||||
{
|
||||
"linear": {
|
||||
"team": "TEAM_KEY",
|
||||
"project": "Project Name",
|
||||
"assignees": {
|
||||
"dev-name": "linear-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
||||
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
|
||||
|
||||
# Linear status names (must match your team's workflow)
|
||||
STATUS_IN_PROGRESS = "In Progress"
|
||||
STATUS_DONE = "Done"
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load local hook config from .trellis/hooks.local.json."""
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if task_json_path:
|
||||
# Walk up from task.json to find .trellis/
|
||||
trellis_dir = Path(task_json_path).parent.parent.parent
|
||||
else:
|
||||
trellis_dir = Path(".trellis")
|
||||
|
||||
config_path = trellis_dir / "hooks.local.json"
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
CONFIG = _load_config()
|
||||
LINEAR_CFG = CONFIG.get("linear", {})
|
||||
|
||||
TEAM = LINEAR_CFG.get("team", "")
|
||||
PROJECT = LINEAR_CFG.get("project", "")
|
||||
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_task() -> tuple[dict, str]:
|
||||
path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not path:
|
||||
print("TASK_JSON_PATH not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f), path
|
||||
|
||||
|
||||
def _write_task(data: dict, path: str) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _linearis(*args: str) -> dict | None:
|
||||
result = subprocess.run(
|
||||
["linearis", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
stdout = result.stdout.strip()
|
||||
if stdout:
|
||||
return json.loads(stdout)
|
||||
return None
|
||||
|
||||
|
||||
def _get_linear_issue(task: dict) -> str | None:
|
||||
meta = task.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
return meta.get("linear_issue")
|
||||
return None
|
||||
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_create() -> None:
|
||||
if not TEAM:
|
||||
print("No linear.team configured in hooks.local.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
task, path = _read_task()
|
||||
|
||||
# Skip if already linked
|
||||
if _get_linear_issue(task):
|
||||
print(f"Already linked: {_get_linear_issue(task)}")
|
||||
return
|
||||
|
||||
title = task.get("title") or task.get("name") or "Untitled"
|
||||
args = ["issues", "create", title, "--team", TEAM]
|
||||
|
||||
# Map priority
|
||||
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
|
||||
if priority:
|
||||
args.extend(["-p", str(priority)])
|
||||
|
||||
# Set project
|
||||
if PROJECT:
|
||||
args.extend(["--project", PROJECT])
|
||||
|
||||
# Assign to Linear user
|
||||
assignee = task.get("assignee", "")
|
||||
linear_user_id = ASSIGNEE_MAP.get(assignee)
|
||||
if linear_user_id:
|
||||
args.extend(["--assignee", linear_user_id])
|
||||
|
||||
# Link to parent's Linear issue if available
|
||||
parent_issue = _resolve_parent_linear_issue(task)
|
||||
if parent_issue:
|
||||
args.extend(["--parent-ticket", parent_issue])
|
||||
|
||||
result = _linearis(*args)
|
||||
if result and "identifier" in result:
|
||||
if not isinstance(task.get("meta"), dict):
|
||||
task["meta"] = {}
|
||||
task["meta"]["linear_issue"] = result["identifier"]
|
||||
_write_task(task, path)
|
||||
print(f"Created Linear issue: {result['identifier']}")
|
||||
|
||||
|
||||
def cmd_start() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
|
||||
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
|
||||
cmd_sync()
|
||||
|
||||
|
||||
def cmd_archive() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_DONE)
|
||||
print(f"Updated {issue} -> {STATUS_DONE}")
|
||||
|
||||
|
||||
def cmd_sync() -> None:
|
||||
"""Sync prd.md content to Linear issue description."""
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
print("No linear_issue in meta, run create first", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find prd.md next to task.json
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
prd_path = Path(task_json_path).parent / "prd.md"
|
||||
if not prd_path.is_file():
|
||||
print(f"No prd.md found at {prd_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
description = prd_path.read_text(encoding="utf-8").strip()
|
||||
_linearis("issues", "update", issue, "-d", description)
|
||||
print(f"Synced prd.md to {issue} description")
|
||||
|
||||
|
||||
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_parent_linear_issue(task: dict) -> str | None:
|
||||
"""Find parent task's Linear issue identifier."""
|
||||
parent_name = task.get("parent")
|
||||
if not parent_name:
|
||||
return None
|
||||
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not task_json_path:
|
||||
return None
|
||||
|
||||
current_task_dir = Path(task_json_path).parent
|
||||
tasks_dir = current_task_dir.parent
|
||||
parent_json = tasks_dir / parent_name / "task.json"
|
||||
|
||||
if parent_json.exists():
|
||||
try:
|
||||
with open(parent_json, encoding="utf-8") as f:
|
||||
parent_task = json.load(f)
|
||||
return _get_linear_issue(parent_task)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
actions = {
|
||||
"create": cmd_create,
|
||||
"start": cmd_start,
|
||||
"archive": cmd_archive,
|
||||
"sync": cmd_sync,
|
||||
}
|
||||
fn = actions.get(action)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown action: {action}", file=sys.stderr)
|
||||
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize developer for workflow.
|
||||
|
||||
Usage:
|
||||
python3 init_developer.py <developer-name>
|
||||
|
||||
This creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_DEVELOPER,
|
||||
get_developer,
|
||||
)
|
||||
from common.developer import init_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||
print()
|
||||
print("Example:")
|
||||
print(f" {sys.argv[0]} john")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
|
||||
# Check if already initialized
|
||||
existing = get_developer()
|
||||
if existing:
|
||||
print(f"Developer already initialized: {existing}")
|
||||
print()
|
||||
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||
sys.exit(0)
|
||||
|
||||
if init_developer(name):
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+500
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Task Management Script.
|
||||
|
||||
Usage:
|
||||
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>]
|
||||
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||||
python3 task.py validate <dir> # Validate jsonl files
|
||||
python3 task.py list-context <dir> # List jsonl entries
|
||||
python3 task.py start <dir> # Set active task
|
||||
python3 task.py current [--source] # Show active task
|
||||
python3 task.py finish # Clear active task
|
||||
python3 task.py set-branch <dir> <branch> # Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||||
python3 task.py archive <task-dir> # Archive completed task
|
||||
python3 task.py list # List active tasks
|
||||
python3 task.py list-archive [month] # List archived tasks
|
||||
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
|
||||
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from common.log import Colors, colored
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_TASKS,
|
||||
FILE_TASK_JSON,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
get_current_task,
|
||||
)
|
||||
from common.active_task import (
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
from common.io import read_json, write_json
|
||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||
from common.tasks import iter_active_tasks, children_progress
|
||||
|
||||
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||
from common.task_store import (
|
||||
cmd_create,
|
||||
cmd_archive,
|
||||
cmd_set_branch,
|
||||
cmd_set_base_branch,
|
||||
cmd_set_scope,
|
||||
cmd_add_subtask,
|
||||
cmd_remove_subtask,
|
||||
)
|
||||
from common.task_context import (
|
||||
cmd_add_context,
|
||||
cmd_validate,
|
||||
cmd_list_context,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: start / finish
|
||||
# =============================================================================
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
"""Set active task."""
|
||||
repo_root = get_repo_root()
|
||||
task_input = args.dir
|
||||
|
||||
if not task_input:
|
||||
print(colored("Error: task directory or name required", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||
full_path = resolve_task_dir(task_input, repo_root)
|
||||
|
||||
if not full_path.is_dir():
|
||||
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
||||
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||||
return 1
|
||||
|
||||
# Convert to relative path for storage
|
||||
try:
|
||||
task_dir = full_path.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
task_dir = str(full_path)
|
||||
|
||||
task_json_path = full_path / FILE_TASK_JSON
|
||||
|
||||
if not resolve_context_key():
|
||||
# Degraded mode: no session identity available.
|
||||
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
|
||||
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
|
||||
# per-session pointer write; AI continues based on conversation context.
|
||||
print(colored(
|
||||
"ℹ Session identity not available; active-task pointer not persisted "
|
||||
"this session (degraded mode). AI continues based on conversation context.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
print(colored(
|
||||
"Hint: run inside an AI IDE/session that exposes session identity, "
|
||||
"or set TRELLIS_CONTEXT_ID before running task.py start.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
|
||||
# Still flip task.json status: planning → in_progress so downstream phases proceed.
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data and data.get("status") == "planning":
|
||||
data["status"] = "in_progress"
|
||||
if write_json(task_json_path, data):
|
||||
print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN))
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
active = set_active_task(task_dir, repo_root)
|
||||
if active:
|
||||
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data and data.get("status") == "planning":
|
||||
data["status"] = "in_progress"
|
||||
if write_json(task_json_path, data):
|
||||
print(colored("✓ Status: planning → in_progress", Colors.GREEN))
|
||||
|
||||
print()
|
||||
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||||
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
else:
|
||||
print(colored("Error: Failed to set current task", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_finish(args: argparse.Namespace) -> int:
|
||||
"""Clear active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = clear_active_task(repo_root)
|
||||
current = active.task_path
|
||||
|
||||
if not current:
|
||||
print(colored("No current task set", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Resolve task.json path before clearing
|
||||
task_json_path = repo_root / current / FILE_TASK_JSON
|
||||
|
||||
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
run_task_hooks("after_finish", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_current(args: argparse.Namespace) -> int:
|
||||
"""Show active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = resolve_active_task(repo_root)
|
||||
|
||||
if args.source:
|
||||
print(f"Current task: {active.task_path or '(none)'}")
|
||||
print(f"Source: {active.source}")
|
||||
if active.stale:
|
||||
print("State: stale")
|
||||
return 0 if active.task_path else 1
|
||||
|
||||
if active.task_path:
|
||||
print(active.task_path)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List active tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
current_task = get_current_task(repo_root)
|
||||
developer = get_developer(repo_root)
|
||||
filter_mine = args.mine
|
||||
filter_status = args.status
|
||||
|
||||
if filter_mine:
|
||||
if not developer:
|
||||
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||||
else:
|
||||
print(colored("All active tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
# Single pass: collect all tasks via shared iterator
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
# Display tasks hierarchically
|
||||
count = 0
|
||||
|
||||
def _print_task(dir_name: str, indent: int = 0) -> None:
|
||||
nonlocal count
|
||||
t = all_tasks[dir_name]
|
||||
|
||||
# Apply --mine filter
|
||||
if filter_mine and (t.assignee or "-") != developer:
|
||||
return
|
||||
|
||||
# Apply --status filter
|
||||
if filter_status and t.status != filter_status:
|
||||
return
|
||||
|
||||
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
|
||||
marker = ""
|
||||
if relative_path == current_task:
|
||||
marker = f" {colored('<- current', Colors.GREEN)}"
|
||||
|
||||
# Children progress
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
|
||||
# Package tag
|
||||
pkg_tag = f" @{t.package}" if t.package else ""
|
||||
|
||||
prefix = " " * indent + " - "
|
||||
|
||||
if filter_mine:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}")
|
||||
else:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||
count += 1
|
||||
|
||||
# Print children indented
|
||||
for child_name in t.children:
|
||||
if child_name in all_tasks:
|
||||
_print_task(child_name, indent + 1)
|
||||
|
||||
# Display only top-level tasks (those without a parent)
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task(dir_name)
|
||||
|
||||
if count == 0:
|
||||
if filter_mine:
|
||||
print(" (no tasks assigned to you)")
|
||||
else:
|
||||
print(" (no active tasks)")
|
||||
|
||||
print()
|
||||
print(f"Total: {count} task(s)")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-archive
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_archive(args: argparse.Namespace) -> int:
|
||||
"""List archived tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
archive_dir = tasks_dir / "archive"
|
||||
month = args.month
|
||||
|
||||
print(colored("Archived tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
if month:
|
||||
month_dir = archive_dir / month
|
||||
if month_dir.is_dir():
|
||||
print(f"[{month}]")
|
||||
for d in sorted(month_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
print(f" - {d.name}/")
|
||||
else:
|
||||
print(f" No archives for {month}")
|
||||
else:
|
||||
if archive_dir.is_dir():
|
||||
for month_dir in sorted(archive_dir.iterdir()):
|
||||
if month_dir.is_dir():
|
||||
month_name = month_dir.name
|
||||
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
|
||||
print(f"[{month_name}] - {count} task(s)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Help
|
||||
# =============================================================================
|
||||
|
||||
def show_usage() -> None:
|
||||
"""Show usage help."""
|
||||
print("""Task Management Script
|
||||
|
||||
Usage:
|
||||
python3 task.py create <title> Create new task directory
|
||||
python3 task.py create <title> --package <pkg> Create task for a specific package
|
||||
python3 task.py create <title> --parent <dir> Create task as child of parent
|
||||
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||
python3 task.py validate <dir> Validate jsonl files
|
||||
python3 task.py list-context <dir> List jsonl entries
|
||||
python3 task.py start <dir> Set active task
|
||||
python3 task.py current [--source] Show active task
|
||||
python3 task.py finish Clear active task
|
||||
python3 task.py set-branch <dir> <branch> Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||
python3 task.py archive <task-dir> Archive completed task
|
||||
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||||
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||||
python3 task.py list [--mine] [--status <status>] List tasks
|
||||
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||
|
||||
Monorepo options:
|
||||
--package <pkg> Package name (validated against config.yaml packages)
|
||||
|
||||
List options:
|
||||
--mine, -m Show only tasks assigned to current developer
|
||||
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||||
|
||||
Examples:
|
||||
python3 task.py create "Add login feature" --slug add-login
|
||||
python3 task.py create "Add login feature" --slug add-login --package cli
|
||||
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
|
||||
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||
python3 task.py set-branch <dir> task/add-login
|
||||
python3 task.py start .trellis/tasks/01-21-add-login
|
||||
python3 task.py current --source
|
||||
python3 task.py finish
|
||||
python3 task.py archive add-login
|
||||
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||||
python3 task.py remove-subtask parent-task child-task
|
||||
python3 task.py list # List all active tasks
|
||||
python3 task.py list --mine # List my tasks only
|
||||
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||||
""")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
|
||||
# Detect early so argparse doesn't mask the real reason with a generic
|
||||
# "invalid choice" error.
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
|
||||
print(
|
||||
colored(
|
||||
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||
print(
|
||||
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Task Management Script",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# create
|
||||
p_create = subparsers.add_parser("create", help="Create new task")
|
||||
p_create.add_argument("title", help="Task title")
|
||||
p_create.add_argument("--slug", "-s", help="Task slug")
|
||||
p_create.add_argument("--assignee", "-a", help="Assignee developer")
|
||||
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
|
||||
p_create.add_argument("--description", "-d", help="Task description")
|
||||
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
|
||||
p_create.add_argument("--package", help="Package name for monorepo projects")
|
||||
|
||||
# add-context
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
p_add.add_argument("dir", help="Task directory")
|
||||
p_add.add_argument("file", help="JSONL file (implement|check)")
|
||||
p_add.add_argument("path", help="File path to add")
|
||||
p_add.add_argument("reason", nargs="?", help="Reason for adding")
|
||||
|
||||
# validate
|
||||
p_validate = subparsers.add_parser("validate", help="Validate context files")
|
||||
p_validate.add_argument("dir", help="Task directory")
|
||||
|
||||
# list-context
|
||||
p_listctx = subparsers.add_parser("list-context", help="List context entries")
|
||||
p_listctx.add_argument("dir", help="Task directory")
|
||||
|
||||
# start
|
||||
p_start = subparsers.add_parser("start", help="Set active task")
|
||||
p_start.add_argument("dir", help="Task directory")
|
||||
|
||||
# current
|
||||
p_current = subparsers.add_parser("current", help="Show active task")
|
||||
p_current.add_argument("--source", action="store_true",
|
||||
help="Show active task source")
|
||||
|
||||
# finish
|
||||
subparsers.add_parser("finish", help="Clear active task")
|
||||
|
||||
# set-branch
|
||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||
p_branch.add_argument("dir", help="Task directory")
|
||||
p_branch.add_argument("branch", help="Branch name")
|
||||
|
||||
# set-base-branch
|
||||
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
|
||||
p_base.add_argument("dir", help="Task directory")
|
||||
p_base.add_argument("base_branch", help="Base branch name (PR target)")
|
||||
|
||||
# set-scope
|
||||
p_scope = subparsers.add_parser("set-scope", help="Set scope")
|
||||
p_scope.add_argument("dir", help="Task directory")
|
||||
p_scope.add_argument("scope", help="Scope name")
|
||||
|
||||
# archive
|
||||
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||||
p_archive.add_argument("name", help="Task directory or name")
|
||||
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
||||
|
||||
# list
|
||||
p_list = subparsers.add_parser("list", help="List tasks")
|
||||
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||||
p_list.add_argument("--status", "-s", help="Filter by status")
|
||||
|
||||
# add-subtask
|
||||
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||||
p_addsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_addsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# remove-subtask
|
||||
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
|
||||
p_rmsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_rmsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# list-archive
|
||||
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
|
||||
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
commands = {
|
||||
"create": cmd_create,
|
||||
"add-context": cmd_add_context,
|
||||
"validate": cmd_validate,
|
||||
"list-context": cmd_list_context,
|
||||
"start": cmd_start,
|
||||
"current": cmd_current,
|
||||
"finish": cmd_finish,
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
"archive": cmd_archive,
|
||||
"add-subtask": cmd_add_subtask,
|
||||
"remove-subtask": cmd_remove_subtask,
|
||||
"list": cmd_list,
|
||||
"list-archive": cmd_list_archive,
|
||||
}
|
||||
|
||||
if args.command in commands:
|
||||
return commands[args.command](args)
|
||||
else:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,223 @@
|
||||
# Code Reuse Thinking Guide
|
||||
|
||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
||||
|
||||
When you copy-paste or rewrite existing logic:
|
||||
- Bug fixes don't propagate
|
||||
- Behavior diverges over time
|
||||
- Codebase becomes harder to understand
|
||||
|
||||
---
|
||||
|
||||
## Before Writing New Code
|
||||
|
||||
### Step 1: Search First
|
||||
|
||||
```bash
|
||||
# Search for similar function names
|
||||
grep -r "functionName" .
|
||||
|
||||
# Search for similar logic
|
||||
grep -r "keyword" .
|
||||
```
|
||||
|
||||
### Step 2: Ask These Questions
|
||||
|
||||
| Question | If Yes... |
|
||||
|----------|-----------|
|
||||
| Does a similar function exist? | Use or extend it |
|
||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
||||
| Could this be a shared utility? | Create it in the right place |
|
||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
||||
|
||||
---
|
||||
|
||||
## Common Duplication Patterns
|
||||
|
||||
### Pattern 1: Copy-Paste Functions
|
||||
|
||||
**Bad**: Copying a validation function to another file
|
||||
|
||||
**Good**: Extract to shared utilities, import where needed
|
||||
|
||||
### Pattern 2: Similar Components
|
||||
|
||||
**Bad**: Creating a new component that's 80% similar to existing
|
||||
|
||||
**Good**: Extend existing component with props/variants
|
||||
|
||||
### Pattern 3: Repeated Constants
|
||||
|
||||
**Bad**: Defining the same constant in multiple files
|
||||
|
||||
**Good**: Single source of truth, import everywhere
|
||||
|
||||
### Pattern 4: Repeated Payload Field Extraction
|
||||
|
||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
||||
|
||||
```typescript
|
||||
const description = (ev as { description?: string }).description;
|
||||
const context = (ev as { context?: ContextEntry[] }).context;
|
||||
```
|
||||
|
||||
This is duplicated contract logic even when the code is only two lines. Each
|
||||
consumer now has its own definition of what a valid payload means.
|
||||
|
||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
||||
|
||||
```typescript
|
||||
if (isThreadEvent(ev)) {
|
||||
renderThreadEvent(ev);
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
||||
shared type guard / normalizer / projection before adding a third reader.
|
||||
|
||||
---
|
||||
|
||||
## When to Abstract
|
||||
|
||||
**Abstract when**:
|
||||
- Same code appears 3+ times
|
||||
- Logic is complex enough to have bugs
|
||||
- Multiple people might need this
|
||||
|
||||
**Don't abstract when**:
|
||||
- Only used once
|
||||
- Trivial one-liner
|
||||
- Abstraction would be more complex than duplication
|
||||
|
||||
---
|
||||
|
||||
## After Batch Modifications
|
||||
|
||||
When you've made similar changes to multiple files:
|
||||
|
||||
1. **Review**: Did you catch all instances?
|
||||
2. **Search**: Run grep to find any missed
|
||||
3. **Consider**: Should this be abstracted?
|
||||
|
||||
### Reducers Should Use Exhaustive Structure
|
||||
|
||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
||||
|
||||
```typescript
|
||||
// BAD - action-specific state transitions are hard to audit
|
||||
if (action === "opened") { ... }
|
||||
else if (action === "comment") { ... }
|
||||
else if (action === "status") { ... }
|
||||
|
||||
// GOOD - one reducer owns the transition table
|
||||
switch (event.action) {
|
||||
case "opened":
|
||||
...
|
||||
return;
|
||||
case "comment":
|
||||
...
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This matters when the event log is the source of truth. A reducer is the
|
||||
documented replay model; display code and commands should not duplicate pieces
|
||||
of that replay model.
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Commit
|
||||
|
||||
- [ ] Searched for existing similar code
|
||||
- [ ] No copy-pasted logic that should be shared
|
||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
||||
- [ ] Constants defined in one place
|
||||
- [ ] Similar patterns follow same structure
|
||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Python if/elif/else Exhaustive Check
|
||||
|
||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
||||
|
||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
||||
|
||||
**Example** (`cli_adapter.py`):
|
||||
```python
|
||||
# BAD: "gemini" falls through to else, returns "claude"
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
else:
|
||||
return "claude" # gemini silently gets "claude"!
|
||||
|
||||
# GOOD: explicit branch for every platform
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
else:
|
||||
return "claude"
|
||||
```
|
||||
|
||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
||||
|
||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
||||
|
||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
||||
|
||||
**Prevention**:
|
||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
||||
|
||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
||||
|
||||
---
|
||||
|
||||
## Template File Registration (Trellis-specific)
|
||||
|
||||
When adding new files to `src/templates/trellis/scripts/`:
|
||||
|
||||
**Single registration point**: `src/templates/trellis/index.ts`
|
||||
|
||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
||||
2. Add to `getAllScripts()` Map
|
||||
|
||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
||||
|
||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
||||
|
||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
||||
|
||||
### Quick Checklist for New Scripts
|
||||
|
||||
```bash
|
||||
# After adding a new .py file, verify it's in getAllScripts():
|
||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
||||
```
|
||||
|
||||
### Template Sync Convention
|
||||
|
||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
||||
|
||||
```bash
|
||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
||||
```
|
||||
|
||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Cross-Layer Thinking Guide
|
||||
|
||||
> **Purpose**: Think through data flow across layers before implementing.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**Most bugs happen at layer boundaries**, not within layers.
|
||||
|
||||
Common cross-layer bugs:
|
||||
|
||||
- API returns format A, frontend expects format B
|
||||
- Database stores X, service transforms to Y, but loses data
|
||||
- Multiple layers implement the same logic differently
|
||||
|
||||
---
|
||||
|
||||
## Before Implementing Cross-Layer Features
|
||||
|
||||
### Step 1: Map the Data Flow
|
||||
|
||||
Draw out how data moves:
|
||||
|
||||
```
|
||||
Source → Transform → Store → Retrieve → Transform → Display
|
||||
```
|
||||
|
||||
For each arrow, ask:
|
||||
|
||||
- What format is the data in?
|
||||
- What could go wrong?
|
||||
- Who is responsible for validation?
|
||||
|
||||
### Step 2: Identify Boundaries
|
||||
|
||||
| Boundary | Common Issues |
|
||||
| --------------------- | --------------------------------- |
|
||||
| API ↔ Service | Type mismatches, missing fields |
|
||||
| Service ↔ Database | Format conversions, null handling |
|
||||
| Backend ↔ Frontend | Serialization, date formats |
|
||||
| Component ↔ Component | Props shape changes |
|
||||
|
||||
### Step 3: Define Contracts
|
||||
|
||||
For each boundary:
|
||||
|
||||
- What is the exact input format?
|
||||
- What is the exact output format?
|
||||
- What errors can occur?
|
||||
|
||||
---
|
||||
|
||||
## Common Cross-Layer Mistakes
|
||||
|
||||
### Mistake 1: Implicit Format Assumptions
|
||||
|
||||
**Bad**: Assuming date format without checking
|
||||
|
||||
**Good**: Explicit format conversion at boundaries
|
||||
|
||||
### Mistake 2: Scattered Validation
|
||||
|
||||
**Bad**: Validating the same thing in multiple layers
|
||||
|
||||
**Good**: Validate once at the entry point
|
||||
|
||||
### Mistake 3: Leaky Abstractions
|
||||
|
||||
**Bad**: Component knows about database schema
|
||||
|
||||
**Good**: Each layer only knows its neighbors
|
||||
|
||||
### Mistake 4: Every Consumer Parses The Same Payload
|
||||
|
||||
**Bad**: A command reads JSONL events and casts fields inline:
|
||||
|
||||
```typescript
|
||||
const thread = (ev as { thread?: string }).thread;
|
||||
const labels = (ev as { labels?: string[] }).labels;
|
||||
```
|
||||
|
||||
This looks local, but it means every consumer owns a private version of the
|
||||
event contract. The next field change will update one command and miss another.
|
||||
|
||||
**Good**: Decode once at the event boundary, then export typed projections:
|
||||
|
||||
```typescript
|
||||
if (!isThreadEvent(ev)) return false;
|
||||
return ev.thread === filter.thread;
|
||||
```
|
||||
|
||||
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
|
||||
create one owner for:
|
||||
|
||||
- event / payload type definitions
|
||||
- type guards and normalization from `unknown`
|
||||
- metadata projections used by UI commands
|
||||
- reducers that replay state from the source of truth
|
||||
|
||||
Rendering code may format fields, but it must not redefine the payload contract.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Cross-Layer Features
|
||||
|
||||
Before implementation:
|
||||
|
||||
- [ ] Mapped the complete data flow
|
||||
- [ ] Identified all layer boundaries
|
||||
- [ ] Defined format at each boundary
|
||||
- [ ] Decided where validation happens
|
||||
|
||||
After implementation:
|
||||
|
||||
- [ ] Tested with edge cases (null, empty, invalid)
|
||||
- [ ] Verified error handling at each boundary
|
||||
- [ ] Checked data survives round-trip
|
||||
- [ ] Checked that consumers import shared decoders / projections instead of
|
||||
casting payload fields locally
|
||||
- [ ] Checked that derived state points back to the source event identifier
|
||||
(`seq`, `id`, `version`) instead of inventing a second cursor
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
---
|
||||
|
||||
## Versioned Documentation Boundary
|
||||
|
||||
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
|
||||
version routing, and the rendered version selector must all describe the same
|
||||
release line.
|
||||
|
||||
### Checklist: Before Editing Versioned Docs
|
||||
|
||||
- [ ] Identify the target release line: stable, beta, or RC
|
||||
- [ ] Verify the edited MDX path matches that line:
|
||||
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
|
||||
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
|
||||
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
|
||||
- [ ] Verify `docs.json` navigation points the version label to the same paths
|
||||
- [ ] Grep the opposite tree for release-line-specific terms before committing
|
||||
- [ ] Treat beta content appearing under root release paths as a source-path bug,
|
||||
not a rendering bug
|
||||
|
||||
**Real-world example**: A beta-only task workflow change documented
|
||||
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
|
||||
mode banners under root `start/` and `advanced/` paths. The docs site then
|
||||
served 0.6 beta behavior under the Release selector. The fix was to restore root
|
||||
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
|
||||
audit for beta markers against the root release tree.
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## When to Create Flow Documentation
|
||||
|
||||
Create detailed flow docs when:
|
||||
|
||||
- Feature spans 3+ layers
|
||||
- Multiple teams are involved
|
||||
- Data format is complex
|
||||
- Feature has caused bugs before
|
||||
|
||||
---
|
||||
|
||||
## Event Log / Projection Boundary
|
||||
|
||||
Append-only logs are cross-layer contracts. A single event travels through:
|
||||
|
||||
```
|
||||
CLI input → event writer → events.jsonl → reader → filter → reducer → display
|
||||
```
|
||||
|
||||
### Checklist: After Adding A New Event Kind Or Field
|
||||
|
||||
- [ ] Add the event kind to the central event taxonomy
|
||||
- [ ] Add a typed event variant or type guard at the event layer
|
||||
- [ ] Add normalization helpers for array/object fields that come from
|
||||
user input or JSON
|
||||
- [ ] Keep `seq` / `id` assignment in the event writer only
|
||||
- [ ] Make filters and reducers consume the typed event guard, not local casts
|
||||
- [ ] Make display code consume reducer output or typed events, not raw JSON
|
||||
- [ ] Add at least one regression that proves history replay and live filtering
|
||||
use the same filter model
|
||||
|
||||
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
|
||||
`context`, labels, and `lastSeq`. The first implementation replayed thread
|
||||
state correctly, but several commands still re-parsed event payload fields with
|
||||
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
|
||||
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
|
||||
projection, and make `reduceThreads` the only thread replay reducer.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Thinking Guides
|
||||
|
||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
||||
|
||||
---
|
||||
|
||||
## Why Thinking Guides?
|
||||
|
||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
||||
|
||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
||||
- Didn't think about edge cases → runtime errors
|
||||
- Didn't think about future maintainers → unreadable code
|
||||
|
||||
These guides help you **ask the right questions before coding**.
|
||||
|
||||
---
|
||||
|
||||
## Available Guides
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
|-------|---------|-------------|
|
||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Thinking Triggers
|
||||
|
||||
### When to Think About Cross-Layer Issues
|
||||
|
||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
||||
- [ ] UI / command code starts casting raw payload fields directly
|
||||
|
||||
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
|
||||
|
||||
### When to Think About Code Reuse
|
||||
|
||||
- [ ] You're writing similar code to something that exists
|
||||
- [ ] You see the same pattern repeated 3+ times
|
||||
- [ ] You're adding a new field to multiple places
|
||||
- [ ] **You're modifying any constant or config**
|
||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
||||
- [ ] Two files read the same untyped payload field with local casts
|
||||
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
||||
|
||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||
|
||||
### When Verifying AI Cross-Review Results
|
||||
|
||||
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
||||
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
||||
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
||||
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
||||
|
||||
**Common AI reviewer false-positive patterns**:
|
||||
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
||||
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
||||
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
||||
|
||||
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
grep -r "value_to_change" .
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Directory
|
||||
|
||||
1. **Before coding**: Skim the relevant thinking guide
|
||||
2. **During coding**: If something feels repetitive or complex, check the guides
|
||||
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it to the relevant guide.
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Component Guidelines
|
||||
|
||||
> How components are built in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's component conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What component patterns do you use?
|
||||
- How are props defined?
|
||||
- How do you handle composition?
|
||||
- What accessibility standards apply?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
<!-- Standard structure of a component file -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Props Conventions
|
||||
|
||||
<!-- How props should be defined and typed -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Styling Patterns
|
||||
|
||||
<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
<!-- A11y requirements and patterns -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- Component-related mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Directory Structure
|
||||
|
||||
> How frontend code is organized in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's frontend directory structure here.
|
||||
|
||||
Questions to answer:
|
||||
- Where do components live?
|
||||
- How are features/modules organized?
|
||||
- Where are shared utilities?
|
||||
- How are assets organized?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
<!-- Replace with your actual structure -->
|
||||
src/
|
||||
├── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
<!-- How should new features be organized? -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
<!-- File and folder naming rules -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
<!-- Link to well-organized modules as examples -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Hook Guidelines
|
||||
|
||||
> How hooks are used in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's hook conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What custom hooks do you have?
|
||||
- How do you handle data fetching?
|
||||
- What are the naming conventions?
|
||||
- How do you share stateful logic?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Custom Hook Patterns
|
||||
|
||||
<!-- How to create and structure custom hooks -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Data Fetching
|
||||
|
||||
<!-- How data fetching is handled (React Query, SWR, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
<!-- Hook naming rules (use*, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- Hook-related mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Frontend Development Guidelines
|
||||
|
||||
> Best practices for frontend development in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains guidelines for frontend development. Fill in each file with your project's specific conventions.
|
||||
|
||||
---
|
||||
|
||||
## Guidelines Index
|
||||
|
||||
| Guide | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| [Directory Structure](./directory-structure.md) | Module organization and file layout | To fill |
|
||||
| [Component Guidelines](./component-guidelines.md) | Component patterns, props, composition | To fill |
|
||||
| [Hook Guidelines](./hook-guidelines.md) | Custom hooks, data fetching patterns | To fill |
|
||||
| [State Management](./state-management.md) | Local state, global state, server state | To fill |
|
||||
| [Quality Guidelines](./quality-guidelines.md) | Code standards, forbidden patterns | To fill |
|
||||
| [Type Safety](./type-safety.md) | Type patterns, validation | To fill |
|
||||
|
||||
---
|
||||
|
||||
## How to Fill These Guidelines
|
||||
|
||||
For each guideline file:
|
||||
|
||||
1. Document your project's **actual conventions** (not ideals)
|
||||
2. Include **code examples** from your codebase
|
||||
3. List **forbidden patterns** and why
|
||||
4. Add **common mistakes** your team has made
|
||||
|
||||
The goal is to help AI assistants and new team members understand how YOUR project works.
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation should be written in **English**.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Quality Guidelines
|
||||
|
||||
> Code quality standards for frontend development.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's quality standards here.
|
||||
|
||||
Questions to answer:
|
||||
- What patterns are forbidden?
|
||||
- What linting rules do you enforce?
|
||||
- What are your testing requirements?
|
||||
- What code review standards apply?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
<!-- Patterns that should never be used and why -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
<!-- Patterns that must always be used -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
<!-- What level of testing is expected -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
<!-- What reviewers should check -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,51 @@
|
||||
# State Management
|
||||
|
||||
> How state is managed in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's state management conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What state management solution do you use?
|
||||
- How is local vs global state decided?
|
||||
- How do you handle server state?
|
||||
- What are the patterns for derived state?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## State Categories
|
||||
|
||||
<!-- Local state, global state, server state, URL state -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## When to Use Global State
|
||||
|
||||
<!-- Criteria for promoting state to global -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Server State
|
||||
|
||||
<!-- How server data is cached and synchronized -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- State management mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Type Safety
|
||||
|
||||
> Type safety patterns in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's type safety conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What type system do you use?
|
||||
- How are types organized?
|
||||
- What validation library do you use?
|
||||
- How do you handle type inference?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Type Organization
|
||||
|
||||
<!-- Where types are defined, shared types vs local types -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- Runtime validation patterns (Zod, Yup, io-ts, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
<!-- Type utilities, generics, type guards -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
<!-- any, type assertions, etc. -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -0,0 +1,119 @@
|
||||
# Bootstrap Task: Fill Project Development Guidelines
|
||||
|
||||
**You (the AI) are running this task. The developer does not read this file.**
|
||||
|
||||
The developer just ran `trellis init` on this project for the first time.
|
||||
`.trellis/` now exists with empty spec scaffolding, and this bootstrap task
|
||||
exists under `.trellis/tasks/`. When they want to work on it, they should start
|
||||
this task from a session that provides Trellis session identity.
|
||||
|
||||
**Your job**: help them populate `.trellis/spec/` with the team's real
|
||||
coding conventions. Every future AI session — this project's
|
||||
`trellis-implement` and `trellis-check` sub-agents — auto-loads spec files
|
||||
listed in per-task jsonl manifests. Empty spec = sub-agents write generic
|
||||
code. Real spec = sub-agents match the team's actual patterns.
|
||||
|
||||
Don't dump instructions. Open with a short greeting, figure out if the repo
|
||||
has any existing convention docs (CLAUDE.md, .cursorrules, etc.), and drive
|
||||
the rest conversationally.
|
||||
|
||||
---
|
||||
|
||||
## Status (update the checkboxes as you complete each item)
|
||||
|
||||
- [ ] Fill guidelines for vendor/ComfyUI_frontend
|
||||
- [ ] Add code examples
|
||||
|
||||
---
|
||||
|
||||
## Spec files to populate
|
||||
|
||||
### Package: vendor/ComfyUI_frontend (`spec/vendor/ComfyUI_frontend/`)
|
||||
|
||||
- Frontend guidelines: `.trellis/spec/vendor/ComfyUI_frontend/frontend/`
|
||||
|
||||
|
||||
### Thinking guides (already populated)
|
||||
|
||||
`.trellis/spec/guides/` contains general thinking guides pre-filled with
|
||||
best practices. Customize only if something clearly doesn't fit this project.
|
||||
|
||||
---
|
||||
|
||||
## How to fill the spec
|
||||
|
||||
### Step 1: Import from existing convention files first (preferred)
|
||||
|
||||
Search the repo for existing convention docs. If any exist, read them and
|
||||
extract the relevant rules into the matching `.trellis/spec/` files —
|
||||
usually much faster than documenting from scratch.
|
||||
|
||||
| File / Directory | Tool |
|
||||
|------|------|
|
||||
| `CLAUDE.md` / `CLAUDE.local.md` | Claude Code |
|
||||
| `AGENTS.md` | Codex / Claude Code / agent-compatible tools |
|
||||
| `.cursorrules` | Cursor |
|
||||
| `.cursor/rules/*.mdc` | Cursor (rules directory) |
|
||||
| `.windsurfrules` | Windsurf |
|
||||
| `.clinerules` | Cline |
|
||||
| `.roomodes` | Roo Code |
|
||||
| `.github/copilot-instructions.md` | GitHub Copilot |
|
||||
| `.vscode/settings.json` → `github.copilot.chat.codeGeneration.instructions` | VS Code Copilot |
|
||||
| `CONVENTIONS.md` / `.aider.conf.yml` | aider |
|
||||
| `CONTRIBUTING.md` | General project conventions |
|
||||
| `.editorconfig` | Editor formatting rules |
|
||||
|
||||
### Step 2: Analyze the codebase for anything not covered by existing docs
|
||||
|
||||
Scan real code to discover patterns. Before writing each spec file:
|
||||
- Find 2-3 real examples of each pattern in the codebase.
|
||||
- Reference real file paths (not hypothetical ones).
|
||||
- Document anti-patterns the team clearly avoids.
|
||||
|
||||
### Step 3: Document reality, not ideals
|
||||
|
||||
**Critical**: write what the code *actually does*, not what it should do.
|
||||
Sub-agents match the spec, so aspirational patterns that don't exist in the
|
||||
codebase will cause sub-agents to write code that looks out of place.
|
||||
|
||||
If the team has known tech debt, document the current state — improvement
|
||||
is a separate conversation, not a bootstrap concern.
|
||||
|
||||
---
|
||||
|
||||
## Quick explainer of the runtime (share when they ask "why do we need spec at all")
|
||||
|
||||
- Every AI coding task spawns two sub-agents: `trellis-implement` (writes
|
||||
code) and `trellis-check` (verifies quality).
|
||||
- Each task has `implement.jsonl` / `check.jsonl` manifests listing which
|
||||
spec files to load.
|
||||
- The platform hook auto-injects those spec files + the task's `prd.md`
|
||||
into every sub-agent prompt, so the sub-agent codes/reviews per team
|
||||
conventions without anyone pasting them manually.
|
||||
- Source of truth: `.trellis/spec/`. That's why filling it well now pays
|
||||
off forever.
|
||||
|
||||
---
|
||||
|
||||
## Completion
|
||||
|
||||
When the developer confirms the checklist items above are done with real
|
||||
examples (not placeholders), guide them to run:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py finish
|
||||
python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines
|
||||
```
|
||||
|
||||
After archive, every new developer who joins this project will get a
|
||||
`00-join-<slug>` onboarding task instead of this bootstrap task.
|
||||
|
||||
---
|
||||
|
||||
## Suggested opening line
|
||||
|
||||
"Welcome to Trellis! Your init just set me up to help you fill the project
|
||||
spec — a one-time setup so every future AI session follows the team's
|
||||
conventions instead of writing generic code. Before we start, do you have
|
||||
any existing convention docs (CLAUDE.md, .cursorrules, CONTRIBUTING.md,
|
||||
etc.) I can pull from, or should I scan the codebase from scratch?"
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "00-bootstrap-guidelines",
|
||||
"name": "00-bootstrap-guidelines",
|
||||
"title": "Bootstrap Guidelines",
|
||||
"description": "Fill in project development guidelines for AI agents",
|
||||
"status": "in_progress",
|
||||
"dev_type": "docs",
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "boos4721",
|
||||
"assignee": "boos4721",
|
||||
"createdAt": "2026-06-20",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": null,
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
".trellis/spec/vendor/ComfyUI_frontend/"
|
||||
],
|
||||
"notes": "First-time setup task created by trellis init (fullstack project)",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
# Development Workflow
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Plan before code** — figure out what to do before you start
|
||||
2. **Specs injected, not remembered** — guidelines are injected via hook/skill, not recalled from memory
|
||||
3. **Persist everything** — research, decisions, and lessons all go to files; conversations get compacted, files don't
|
||||
4. **Incremental development** — one task at a time
|
||||
5. **Capture learnings** — after each task, review and write new knowledge back to spec
|
||||
|
||||
---
|
||||
|
||||
## Trellis System
|
||||
|
||||
### Developer Identity
|
||||
|
||||
On first use, initialize your identity:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||
```
|
||||
|
||||
Creates `.trellis/.developer` (gitignored) + `.trellis/workspace/<your-name>/`.
|
||||
|
||||
### Spec System
|
||||
|
||||
`.trellis/spec/` holds coding guidelines organized by package and layer.
|
||||
|
||||
- `.trellis/spec/<package>/<layer>/index.md` — entry point with **Pre-Development Checklist** + **Quality Check**. Actual guidelines live in the `.md` files it points to.
|
||||
- `.trellis/spec/guides/index.md` — cross-package thinking guides.
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages # list packages / layers
|
||||
```
|
||||
|
||||
**When to update spec**: new pattern/convention found · bug-fix prevention to codify · new technical decision.
|
||||
|
||||
### Task System
|
||||
|
||||
Every task has its own directory under `.trellis/tasks/{MM-DD-name}/` holding `task.json`, `prd.md`, optional `design.md`, optional `implement.md`, optional `research/`, and context manifests (`implement.jsonl`, `check.jsonl`) for sub-agent-capable platforms.
|
||||
|
||||
```bash
|
||||
# Task lifecycle
|
||||
python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] [--parent <dir>]
|
||||
python3 ./.trellis/scripts/task.py start <name> # set active task (session-scoped when available)
|
||||
python3 ./.trellis/scripts/task.py current --source # show active task and source
|
||||
python3 ./.trellis/scripts/task.py finish # clear active task (triggers after_finish hooks)
|
||||
python3 ./.trellis/scripts/task.py archive <name> # move to archive/{year-month}/
|
||||
python3 ./.trellis/scripts/task.py list [--mine] [--status <s>]
|
||||
python3 ./.trellis/scripts/task.py list-archive
|
||||
|
||||
# Code-spec context (injected into implement/check agents via JSONL).
|
||||
# `implement.jsonl` / `check.jsonl` are seeded on `task create` for sub-agent-capable
|
||||
# platforms; the AI curates real spec + research entries during planning when needed.
|
||||
python3 ./.trellis/scripts/task.py add-context <name> <action> <file> <reason>
|
||||
python3 ./.trellis/scripts/task.py list-context <name> [action]
|
||||
python3 ./.trellis/scripts/task.py validate <name>
|
||||
|
||||
# Task metadata
|
||||
python3 ./.trellis/scripts/task.py set-branch <name> <branch>
|
||||
python3 ./.trellis/scripts/task.py set-base-branch <name> <branch> # PR target
|
||||
python3 ./.trellis/scripts/task.py set-scope <name> <scope>
|
||||
|
||||
# Hierarchy (parent/child)
|
||||
python3 ./.trellis/scripts/task.py add-subtask <parent> <child>
|
||||
python3 ./.trellis/scripts/task.py remove-subtask <parent> <child>
|
||||
|
||||
# PR creation
|
||||
python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run]
|
||||
```
|
||||
|
||||
> Run `python3 ./.trellis/scripts/task.py --help` to see the authoritative, up-to-date list.
|
||||
|
||||
**Current-task mechanism**: `task.py create` creates the task directory and (when session identity is available) auto-sets the per-session active-task pointer so the planning breadcrumb fires immediately. `task.py start` writes the same pointer (idempotent if already set) and flips `task.json.status` from `planning` to `in_progress`. State is stored under `.trellis/.runtime/sessions/`. If no context key is available from hook input, `TRELLIS_CONTEXT_ID`, or a platform-native session environment variable, there is no active task and `task.py start` fails with a session identity hint. `task.py finish` deletes the current session file (status unchanged). `task.py archive <task>` writes `status=completed`, moves the directory to `archive/`, and deletes any runtime session files that still point at the archived task.
|
||||
|
||||
### Workspace System
|
||||
|
||||
Records every AI session for cross-session tracking under `.trellis/workspace/<developer>/`.
|
||||
|
||||
- `journal-N.md` — session log. **Max 2000 lines per file**; a new `journal-(N+1).md` is auto-created when exceeded.
|
||||
- `index.md` — personal index (total sessions, last active).
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" --summary "Summary"
|
||||
```
|
||||
|
||||
### Context Script
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_context.py # full session runtime
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages # available packages + spec layers
|
||||
python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y> # detailed guide for a workflow step
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below)
|
||||
|
||||
The [workflow-state:STATUS] blocks embedded in the ## Phase Index section
|
||||
below are the SINGLE source of truth for the per-turn `<workflow-state>`
|
||||
breadcrumb that every supported AI platform's UserPromptSubmit hook
|
||||
reads. inject-workflow-state.py (Python platforms) and
|
||||
inject-workflow-state.js (OpenCode plugin) only parse them — there is no
|
||||
fallback dict baked into the scripts after v0.5.0-rc.0.
|
||||
|
||||
STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it
|
||||
degrades to a generic "Refer to workflow.md for current step." line —
|
||||
intentionally visible so users notice and fix a broken workflow.md.
|
||||
|
||||
INVARIANT (test/regression.test.ts):
|
||||
Every workflow-walkthrough step marked `[required · once]` must have a
|
||||
matching enforcement line in its phase's [workflow-state:*] block. The
|
||||
breadcrumb is the only per-turn channel; if a mandatory step isn't
|
||||
mentioned there, the AI silently skips it (Phase 1 planning gate
|
||||
skip and Phase 3.4 commit skip both manifested via this gap).
|
||||
|
||||
TAG ↔ PHASE scoping:
|
||||
[workflow-state:no_task] → no active task; before Phase 1
|
||||
[workflow-state:planning] → all of Phase 1 (status='planning')
|
||||
[workflow-state:planning-inline] → Codex inline variant of Phase 1
|
||||
[workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
|
||||
(status stays 'in_progress' from
|
||||
task.py start until task.py archive)
|
||||
[workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3
|
||||
[workflow-state:completed] → currently DEAD: cmd_archive flips
|
||||
status and moves the dir in the same
|
||||
call, so the resolver loses the
|
||||
pointer (block kept for a future
|
||||
explicit in_progress→completed
|
||||
transition)
|
||||
|
||||
Editing checklist:
|
||||
- When you change a [workflow-state:STATUS] block, also check the
|
||||
matching phase's `[required · once]` walkthrough steps for sync
|
||||
- Run `trellis update` after editing to push the new bodies to
|
||||
downstream user projects (block-level managed replacement)
|
||||
- Full runtime contract:
|
||||
.trellis/spec/cli/backend/workflow-state-contract.md
|
||||
-->
|
||||
|
||||
## Phase Index
|
||||
|
||||
```
|
||||
Phase 1: Plan → classify, get task-creation consent, then write planning artifacts
|
||||
Phase 2: Execute → implement only after task status is in_progress
|
||||
Phase 3: Finish → verify, update spec, commit, and wrap up
|
||||
```
|
||||
|
||||
### Request Triage
|
||||
|
||||
- Simple conversation or small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||
- Complex task: ask whether you may create a Trellis task and enter planning. If the user says no, do not do broad inline implementation; explain, clarify scope, or suggest a smaller split.
|
||||
- User approval to create a task is not approval to start implementation. Planning still happens first.
|
||||
|
||||
### Planning Artifacts
|
||||
|
||||
- `prd.md` — requirements, constraints, and acceptance criteria. Do not put technical design or execution checklists here.
|
||||
- `design.md` — technical design for complex tasks: boundaries, contracts, data flow, tradeoffs, compatibility, rollout / rollback shape.
|
||||
- `implement.md` — execution plan for complex tasks: ordered checklist, validation commands, review gates, and rollback points.
|
||||
- `implement.jsonl` / `check.jsonl` — spec and research manifests for sub-agent context. They do not replace `implement.md`.
|
||||
- Lightweight tasks may be PRD-only. Complex tasks must have `prd.md`, `design.md`, and `implement.md` before `task.py start`.
|
||||
|
||||
### Parent / Child Task Trees
|
||||
|
||||
Use a parent task when one user request contains several independently verifiable deliverables. The parent task owns the source requirement set, the task map, cross-child acceptance criteria, and final integration review; it normally should not be the implementation target unless it also has direct work.
|
||||
|
||||
Use child tasks for deliverables that can be planned, implemented, checked, and archived independently. Parent/child structure is not a dependency system: if one child must wait for another, write that ordering in the child `prd.md` / `implement.md` and keep each child's acceptance criteria testable.
|
||||
|
||||
Create new children with `task.py create "<title>" --slug <name> --parent <parent-dir>`. Link existing tasks with `task.py add-subtask <parent> <child>`, and unlink mistakes with `task.py remove-subtask <parent> <child>`.
|
||||
|
||||
<!-- Per-turn breadcrumb: shown when there is no active task (before Phase 1) -->
|
||||
|
||||
[workflow-state:no_task]
|
||||
No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task.
|
||||
Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session.
|
||||
Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split.
|
||||
[/workflow-state:no_task]
|
||||
|
||||
### Phase 1: Plan
|
||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||
- 1.2 Research `[optional · repeatable]`
|
||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi (sub-agent-dispatch platforms only; inline platforms skip)
|
||||
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||
- 1.5 Completion criteria
|
||||
|
||||
<!-- Per-turn breadcrumb: shown throughout Phase 1 (status='planning') -->
|
||||
|
||||
[workflow-state:planning]
|
||||
Load `trellis-brainstorm`; stay in planning.
|
||||
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||
Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start.
|
||||
[/workflow-state:planning]
|
||||
|
||||
<!-- Per-turn breadcrumb: shown throughout Phase 1 when codex.dispatch_mode=inline.
|
||||
Codex-only opt-in alternate to [workflow-state:planning]. The main agent
|
||||
edits code directly in Phase 2, so jsonl curation is skipped —
|
||||
the inline workflow loads `trellis-before-dev` instead of injecting JSONL
|
||||
into a sub-agent. -->
|
||||
|
||||
[workflow-state:planning-inline]
|
||||
Load `trellis-brainstorm`; stay in planning.
|
||||
Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`.
|
||||
Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position.
|
||||
Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`.
|
||||
[/workflow-state:planning-inline]
|
||||
|
||||
### Phase 2: Execute
|
||||
- 2.1 Implement `[required · repeatable]`
|
||||
- 2.2 Quality check `[required · repeatable]`
|
||||
- 2.3 Rollback `[on demand]`
|
||||
|
||||
<!-- Per-turn breadcrumb: shown while status='in_progress'.
|
||||
Scope: all of Phase 2 + Phase 3.2-3.4 (status stays 'in_progress' from
|
||||
task.py start until task.py archive; only archive flips it). The body
|
||||
therefore must cover every required step from implementation through
|
||||
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
||||
|
||||
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
|
||||
|
||||
[workflow-state:in_progress]
|
||||
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
||||
Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||
Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only.
|
||||
Dispatch prompt starts with `Active task: <task path from task.py current>`. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`.
|
||||
[/workflow-state:in_progress]
|
||||
|
||||
<!-- Per-turn breadcrumb: shown while status='in_progress' when
|
||||
codex.dispatch_mode=inline. Codex-only opt-in alternate to
|
||||
[workflow-state:in_progress]. The main session edits code directly
|
||||
instead of dispatching sub-agents. -->
|
||||
|
||||
[workflow-state:in_progress-inline]
|
||||
Flow: `trellis-before-dev` -> edit -> `trellis-check` -> validation -> `trellis-update-spec` -> commit (Phase 3.4) -> `/trellis:finish-work`.
|
||||
Do not dispatch implement/check sub-agents in inline mode.
|
||||
Read context: `prd.md` -> `design.md if present` -> `implement.md if present`, plus relevant spec/research loaded by skills.
|
||||
[/workflow-state:in_progress-inline]
|
||||
|
||||
### Phase 3: Finish
|
||||
- 3.2 Debug retrospective `[on demand]`
|
||||
- 3.3 Spec update `[required · once]`
|
||||
- 3.4 Commit changes `[required · once]`
|
||||
- 3.5 Wrap-up reminder
|
||||
|
||||
> Note: step 3.1 was folded into 2.2 (last-iteration full-scope check) and 3.4 (commit preamble). Numbering kept stable to avoid breaking external references.
|
||||
|
||||
<!-- Per-turn breadcrumb: shown while status='completed'.
|
||||
Currently DEAD in normal flow: cmd_archive writes status='completed' in
|
||||
the same call that moves the task dir to archive/, so the active-task
|
||||
resolver loses the pointer and the hook never fires on archived tasks.
|
||||
Block preserved for a future status-transition redesign (e.g. an
|
||||
explicit in_progress→completed command). Edit through the same spec
|
||||
channel as the live blocks. -->
|
||||
|
||||
[workflow-state:completed]
|
||||
Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
||||
[/workflow-state:completed]
|
||||
|
||||
### Rules
|
||||
|
||||
1. Identify which Phase you're in, then continue from the next step there
|
||||
2. Run steps in order inside each Phase; `[required]` steps can't be skipped
|
||||
3. Phases can roll back (e.g., Execute reveals a prd defect → return to Plan to fix, then re-enter Execute)
|
||||
4. Steps tagged `[once]` are skipped if the output already exists; don't re-run
|
||||
5. Artifact presence informs the next step; missing `design.md` / `implement.md` is valid for lightweight tasks and incomplete planning for complex tasks.
|
||||
|
||||
### Active Task Routing
|
||||
|
||||
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||
- Before editing -> `trellis-before-dev`; after editing -> `trellis-check`.
|
||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
### Guardrails
|
||||
|
||||
- Task creation approval is not implementation approval; implementation waits for `task.py start` after artifact review.
|
||||
- PRD-only is valid for lightweight tasks; complex tasks need `design.md` + `implement.md`.
|
||||
- Planning must be persisted to task artifacts; checks must run before reporting completion.
|
||||
|
||||
### Loading Step Detail
|
||||
|
||||
At each step, run this to fetch detailed guidance:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_context.py --mode phase --step <step>
|
||||
# e.g. python3 ./.trellis/scripts/get_context.py --mode phase --step 1.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Plan
|
||||
|
||||
Goal: classify the request, get task-creation consent when a task is needed, and produce the planning artifacts required before implementation.
|
||||
|
||||
#### 1.0 Create task `[required · once]`
|
||||
|
||||
Create the task directory only after task-creation consent. The command sets status to `planning`, writes `task.json`, creates a default `prd.md`, and auto-targets the new task when session identity is available:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py create "<task title>" --slug <name>
|
||||
```
|
||||
|
||||
`--slug` is the human-readable name only. Do **not** include the `MM-DD-` date prefix; `task.py create` adds that prefix automatically.
|
||||
|
||||
For task trees, create the parent task first and then create each child with `--parent <parent-dir>`. Do not start the parent just because children exist; start the child that owns the next independently verifiable deliverable.
|
||||
|
||||
After this command succeeds, the per-turn breadcrumb auto-switches to `[workflow-state:planning]`, telling the AI to stay in planning.
|
||||
|
||||
Run only `create` here — do not also run `start`. `start` flips status to `in_progress`, which switches the breadcrumb to the implementation phase before planning artifacts are reviewed. Save `start` for step 1.4.
|
||||
|
||||
Skip when `python3 ./.trellis/scripts/task.py current --source` already points to a task.
|
||||
|
||||
#### 1.1 Requirement exploration `[required · repeatable]`
|
||||
|
||||
Load the `trellis-brainstorm` skill and explore requirements interactively with the user per the skill's guidance.
|
||||
|
||||
The brainstorm skill will guide you to:
|
||||
- Ask one question at a time
|
||||
- Prefer researching over asking the user
|
||||
- Prefer offering options over open-ended questions
|
||||
- Update `prd.md` immediately after each user answer
|
||||
- Split large scopes into a parent task plus child tasks when the deliverables can be verified independently
|
||||
- Keep `prd.md` focused on requirements and acceptance criteria
|
||||
- For complex tasks, produce `design.md` and `implement.md` before implementation starts
|
||||
|
||||
When considering a parent/child split:
|
||||
- Use a parent task when one request contains several independently verifiable deliverables.
|
||||
- Parent tasks own source requirements, child-task mapping, cross-child acceptance criteria, and final integration review.
|
||||
- Child tasks own actual deliverables that can be planned, implemented, checked, and archived independently.
|
||||
- Parent/child structure is not a dependency system. If child B depends on child A, write that ordering in child B's `prd.md` / `implement.md`.
|
||||
- Start the child task that owns the next deliverable. Do not start the parent unless the parent itself has direct implementation work.
|
||||
|
||||
Return to this step whenever requirements change and revise the relevant artifact.
|
||||
|
||||
#### 1.2 Research `[optional · repeatable]`
|
||||
|
||||
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
Spawn the research sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-research`
|
||||
- **Task description**: Research <specific question>
|
||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.)
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
**Research artifact conventions**:
|
||||
- One file per research topic (e.g. `research/auth-library-comparison.md`)
|
||||
- Record third-party library usage examples, API references, version constraints in files
|
||||
- Note relevant spec file paths you discovered for later reference
|
||||
|
||||
Brainstorm and research can interleave freely — pause to research a technical question, then return to talk with the user.
|
||||
|
||||
**Key principle**: Research output must be written to files, not left only in the chat. Conversations get compacted; files don't.
|
||||
|
||||
#### 1.3 Configure context `[required · once]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||
|
||||
**Location**: `{TASK_DIR}/implement.jsonl` and `{TASK_DIR}/check.jsonl` (already exist).
|
||||
|
||||
**Format**: one JSON object per line — `{"file": "<path>", "reason": "<why>"}`. Paths are repo-root relative.
|
||||
|
||||
**What to put in**:
|
||||
- **Spec files** — `.trellis/spec/<package>/<layer>/index.md` and any specific guideline files (`error-handling.md`, `conventions.md`, etc.) relevant to this task
|
||||
- **Research files** — `{TASK_DIR}/research/*.md` that the sub-agent will need to consult
|
||||
|
||||
**What NOT to put in**:
|
||||
- Code files (`src/**`, `packages/**/*.ts`, etc.) — those are read by the sub-agent during implementation, not pre-registered here
|
||||
- Files you're about to modify — same reason
|
||||
|
||||
**Split between the two files**:
|
||||
- `implement.jsonl` → specs + research the implement sub-agent needs to write code correctly
|
||||
- `check.jsonl` → specs for the check sub-agent (quality guidelines, check conventions, same research if needed)
|
||||
|
||||
These manifests do not replace `implement.md`. `implement.md` is the human-readable execution plan for a complex task; jsonl files only list context files to inject or load.
|
||||
|
||||
**How to discover relevant specs**:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_context.py --mode packages
|
||||
```
|
||||
|
||||
Lists every package + its spec layers with paths. Pick the entries that match this task's domain.
|
||||
|
||||
**How to append entries**:
|
||||
|
||||
Either edit the jsonl file directly in your editor, or use:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>"
|
||||
python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>"
|
||||
```
|
||||
|
||||
Delete the seed `_example` line once real entries exist (optional — it's skipped automatically by consumers).
|
||||
|
||||
Skip when: `implement.jsonl` and `check.jsonl` have agent-curated entries (the seed row alone doesn't count).
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Skip this step. Context is loaded directly by the `trellis-before-dev` skill in Phase 2.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
#### 1.4 Activate task `[required · once]`
|
||||
|
||||
After artifact review, flip the task status to `in_progress`:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/task.py start <task-dir>
|
||||
```
|
||||
|
||||
For lightweight tasks, `prd.md` can be enough. For complex tasks, `prd.md`, `design.md`, and `implement.md` must exist and be reviewed before start. On sub-agent-capable platforms, curate jsonl manifests when extra spec or research context is needed; seed-only manifests are tolerated by consumers.
|
||||
|
||||
After this command succeeds, the breadcrumb auto-switches to `[workflow-state:in_progress]`, and the rest of Phase 2 / 3 follows.
|
||||
|
||||
If `task.py start` errors with a session-identity message (no context key from hook input, `TRELLIS_CONTEXT_ID`, or platform-native session env), follow the hint in the error to set up session identity, then retry.
|
||||
|
||||
#### 1.5 Completion criteria
|
||||
|
||||
| Condition | Required |
|
||||
|------|:---:|
|
||||
| `prd.md` exists | ✅ |
|
||||
| User confirms task should enter implementation | ✅ |
|
||||
| `task.py start` has been run (status = in_progress) | ✅ |
|
||||
| `research/` has artifacts (complex tasks) | recommended |
|
||||
| `design.md` exists (complex tasks) | ✅ |
|
||||
| `implement.md` exists (complex tasks) | ✅ |
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
| `implement.jsonl` / `check.jsonl` curated when extra spec or research context is needed | recommended |
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Execute
|
||||
|
||||
Goal: turn reviewed planning artifacts into code that passes quality checks.
|
||||
|
||||
#### 2.1 Implement `[required · repeatable]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-implement`
|
||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||
|
||||
The platform hook/plugin auto-handles:
|
||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
[codex-sub-agent]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-implement`
|
||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then explicitly say the spawned agent is already `trellis-implement` and must implement directly without spawning another `trellis-implement` / `trellis-check`.
|
||||
|
||||
The Codex sub-agent definition auto-handles the context load requirement:
|
||||
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||
|
||||
[/codex-sub-agent]
|
||||
|
||||
[Kiro]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-implement`
|
||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||
|
||||
The platform prelude auto-handles the context load requirement:
|
||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
|
||||
[/Kiro]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
1. Load the `trellis-before-dev` skill to read project guidelines
|
||||
2. Read `{TASK_DIR}/prd.md`, then `design.md` if present, then `implement.md` if present
|
||||
3. Consult materials under `{TASK_DIR}/research/`
|
||||
4. Implement the code per reviewed artifacts
|
||||
5. Run project lint and type-check
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
#### 2.2 Quality check `[required · repeatable]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
Spawn the check sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-check`
|
||||
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||
|
||||
The check agent's job:
|
||||
- Review code changes against specs
|
||||
- Review code changes against `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
- Auto-fix issues it finds
|
||||
- Run lint and typecheck to verify
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Load the `trellis-check` skill and verify the code per its guidance:
|
||||
- Spec compliance
|
||||
- lint / type-check / tests
|
||||
- Cross-layer consistency (when changes span layers)
|
||||
|
||||
If issues are found → fix → re-check, until green.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
**Final pass (before Phase 3.4 commit)**: the last 2.2 of a task must run full-scope, not just on the latest implement chunk. List all affected packages with `python3 ./.trellis/scripts/get_context.py --mode packages`, then load each package's spec index Quality Check section. This catches cross-layer / multi-package issues a mid-iteration local 2.2 cannot.
|
||||
|
||||
#### 2.3 Rollback `[on demand]`
|
||||
|
||||
- `check` reveals a prd defect → return to Phase 1, fix `prd.md`, then redo 2.1
|
||||
- Implementation went wrong → revert code, redo 2.1
|
||||
- Need more research → research (same as Phase 1.2), write findings into `research/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Finish
|
||||
|
||||
Goal: ensure code quality, capture lessons, record the work.
|
||||
|
||||
#### 3.2 Debug retrospective `[on demand]`
|
||||
|
||||
If this task involved repeated debugging (the same issue was fixed multiple times), load the `trellis-break-loop` skill to:
|
||||
- Classify the root cause
|
||||
- Explain why earlier fixes failed
|
||||
- Propose prevention
|
||||
|
||||
The goal is to capture debugging lessons so the same class of issue doesn't recur.
|
||||
|
||||
#### 3.3 Spec update `[required · once]`
|
||||
|
||||
Load the `trellis-update-spec` skill and review whether this task produced new knowledge worth recording:
|
||||
- Newly discovered patterns or conventions
|
||||
- Pitfalls you hit
|
||||
- New technical decisions
|
||||
|
||||
Update the docs under `.trellis/spec/` accordingly. Even if the conclusion is "nothing to update", walk through the judgment.
|
||||
|
||||
#### 3.4 Commit changes `[required · once]`
|
||||
|
||||
**Spec-sync preamble**: before drafting commits, ask: did this task fix a bug or surface non-obvious knowledge that should land in `.trellis/spec/` so future-you (or future-AI) doesn't repeat the mistake? If yes, return to Phase 3.3 first — spec writes belong in the same task's commit batch, not as a forgotten follow-up.
|
||||
|
||||
The AI drives a batched commit of this task's code changes so `/finish-work` can run cleanly afterwards. Goal: produce work commits FIRST, then bookkeeping (archive + journal) commits land after — never interleaved.
|
||||
|
||||
**Step-by-step**:
|
||||
|
||||
1. **Inspect dirty state**:
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
Snapshot every dirty path. If the working tree is clean, skip to 3.5.
|
||||
|
||||
2. **Learn commit style** from recent history (so drafted messages blend in):
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
Note the prefix convention (`feat:` / `fix:` / `chore:` / `docs:` ...), language (中文/English), and length style.
|
||||
|
||||
3. **Classify dirty files into two groups**:
|
||||
- **AI-edited this session** — files you wrote/edited via Edit/Write/Bash tool calls in this session. You know what changed and why.
|
||||
- **Unrecognized** — dirty files you did NOT touch this session (could be the user's manual edits, leftover WIP from a previous session, or unrelated work). Do NOT silently include these.
|
||||
|
||||
4. **Draft a commit plan**. Group AI-edited files into logical commits (1 commit per coherent change unit, not 1 commit per file). Each entry: `<commit message>` + file list. List unrecognized files separately at the bottom.
|
||||
|
||||
5. **Present the plan once, ask for one-shot confirmation**. Format:
|
||||
```
|
||||
Proposed commits (in order):
|
||||
1. <message>
|
||||
- <file>
|
||||
- <file>
|
||||
2. <message>
|
||||
- <file>
|
||||
|
||||
Unrecognized dirty files (NOT in any commit — confirm include/exclude):
|
||||
- <file>
|
||||
- <file>
|
||||
|
||||
Reply 'ok' / '行' to execute. Reply with edits, or '我自己来' / 'manual' to abort.
|
||||
```
|
||||
|
||||
6. **On confirmation**: run `git add <files>` + `git commit -m "<msg>"` for each batch in order. Do not amend. Do not push.
|
||||
|
||||
7. **On rejection** (user replies "不行" / "我自己来" / "manual" / any pushback on the plan): stop. Do not attempt a second plan. The user will commit by hand; you skip ahead to 3.5 once they confirm.
|
||||
|
||||
**Rules**:
|
||||
- No `git commit --amend` anywhere — three-stage three-commit flow (work commits → archive commit → journal commit).
|
||||
- Never push to remote in this step.
|
||||
- If the user wants different message wording but accepts the file grouping, edit the message and re-confirm once — but if they reject the grouping, exit to manual mode.
|
||||
- The batched plan is one prompt; do not prompt per commit.
|
||||
|
||||
#### 3.5 Wrap-up reminder
|
||||
|
||||
After the above, remind the user they can run `/finish-work` to wrap up (archive the task, record the session).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Trellis (for forks)
|
||||
|
||||
This section is for developers who want to modify the Trellis workflow itself. All customization is done by editing this file; the scripts are parsers only.
|
||||
|
||||
### Changing what a step means
|
||||
|
||||
Edit the corresponding step's walkthrough body in the Phase 1 / 2 / 3 sections above. Critical invariants:
|
||||
- No active task must triage first and ask for task-creation consent before creating a Trellis task.
|
||||
- Planning must distinguish lightweight PRD-only tasks from complex tasks that require `prd.md`, `design.md`, and `implement.md` before start.
|
||||
- Every required execution path must keep the Phase 3.4 commit reminder reachable before `/trellis:finish-work`.
|
||||
|
||||
All tag blocks live in the `## Phase Index` section above, immediately after each phase summary:
|
||||
|
||||
| Scope | Corresponding tag |
|
||||
|---|---|
|
||||
| No active task (before Phase 1) | `[workflow-state:no_task]` (after the Phase Index ASCII art) |
|
||||
| All of Phase 1 (task created → ready for implementation) | `[workflow-state:planning]` (after Phase 1 summary) |
|
||||
| Codex inline Phase 1 | `[workflow-state:planning-inline]` |
|
||||
| Phase 2 + Phase 3.2–3.4 (implementation + check + wrap-up) | `[workflow-state:in_progress]` (after Phase 2 summary) |
|
||||
| Codex inline Phase 2 + Phase 3.2–3.4 | `[workflow-state:in_progress-inline]` |
|
||||
| After Phase 3.5 (archived) | `[workflow-state:completed]` (after Phase 3 summary; **currently DEAD**) |
|
||||
|
||||
### Changing the per-turn prompt text
|
||||
|
||||
Directly edit the body of the corresponding `[workflow-state:STATUS]` block. After editing, run `trellis update` (if you're a template maintainer) or restart your AI session (if you're customizing your own project) — no script changes required.
|
||||
|
||||
### Adding a custom status
|
||||
|
||||
Add a new block:
|
||||
|
||||
```
|
||||
[workflow-state:my-status]
|
||||
your per-turn prompt text
|
||||
[/workflow-state:my-status]
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- STATUS charset: `[A-Za-z0-9_-]+` (underscores and hyphens allowed, e.g. `in-review`, `blocked-by-team`)
|
||||
- A lifecycle hook must write `task.json.status` to your custom value, otherwise the tag is never read
|
||||
- Lifecycle hooks live in `task.json.hooks.after_*` and bind to one of `after_create / after_start / after_finish / after_archive`
|
||||
|
||||
### Adding a lifecycle hook
|
||||
|
||||
Add a `hooks` field to your `task.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"after_finish": [
|
||||
"your-script-or-command-here"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported events: `after_create / after_start / after_finish / after_archive`. Note that `after_finish` ≠ a status change (it only clears the active-task pointer); use `after_archive` for "task is done" notifications.
|
||||
|
||||
### Full contract
|
||||
|
||||
For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see:
|
||||
|
||||
- `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants
|
||||
- `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Workspace Index - boos4721
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
@@ -0,0 +1,7 @@
|
||||
# Journal - boos4721 (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-06-20
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Workspace Index
|
||||
|
||||
> Records of all AI Agent work records across all developers
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This directory tracks records for all developers working with AI Agents on this project.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
workspace/
|
||||
|-- index.md # This file - main index
|
||||
+-- {developer}/ # Per-developer directory
|
||||
|-- index.md # Personal index with session history
|
||||
|-- tasks/ # Task files
|
||||
| |-- *.json # Active tasks
|
||||
| +-- archive/ # Archived tasks by month
|
||||
+-- journal-N.md # Journal files (sequential: 1, 2, 3...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Active Developers
|
||||
|
||||
| Developer | Last Active | Sessions | Active File |
|
||||
|-----------|-------------|----------|-------------|
|
||||
| (none yet) | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### For New Developers
|
||||
|
||||
Run the initialization script:
|
||||
|
||||
```bash
|
||||
python3 ./.trellis/scripts/init_developer.py <your-name>
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Create your identity file (gitignored)
|
||||
2. Create your progress directory
|
||||
3. Create your personal index
|
||||
4. Create initial journal file
|
||||
|
||||
### For Returning Developers
|
||||
|
||||
1. Get your developer name:
|
||||
```bash
|
||||
python3 ./.trellis/scripts/get_developer.py
|
||||
```
|
||||
|
||||
2. Read your personal index:
|
||||
```bash
|
||||
cat .trellis/workspace/$(python3 ./.trellis/scripts/get_developer.py)/index.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Journal File Rules
|
||||
|
||||
- **Max 2000 lines** per journal file
|
||||
- When limit is reached, create `journal-{N+1}.md`
|
||||
- Update your personal `index.md` when creating new files
|
||||
|
||||
### Session Record Format
|
||||
|
||||
Each session should include:
|
||||
- Summary: One-line description
|
||||
- Branch: Which branch the work was done on
|
||||
- Main Changes: What was modified
|
||||
- Git Commits: Commit hashes and messages
|
||||
- Next Steps: What to do next
|
||||
|
||||
---
|
||||
|
||||
## Session Template
|
||||
|
||||
Use this template when recording sessions:
|
||||
|
||||
```markdown
|
||||
## Session {N}: {Title}
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Task**: {task-name}
|
||||
**Branch**: `{branch-name}`
|
||||
|
||||
### Summary
|
||||
|
||||
{One-line summary}
|
||||
|
||||
### Main Changes
|
||||
|
||||
- {Change 1}
|
||||
- {Change 2}
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `abc1234` | {commit message} |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] {Test result}
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed** / # **In Progress** / [P] **Blocked**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- {Next step 1}
|
||||
- {Next step 2}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
@@ -0,0 +1,89 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
BoosAPI is an AI API gateway: an OpenAI/Anthropic/Gemini-compatible proxy with a token-routing/failover engine, a management console (React SPA), embedded ComfyUI workflow surfaces, billing, and payments. Backend is **pure Rust** (axum + sqlx + tokio); there is no Node.js runtime dependency at serving time — Node is only used to build frontend assets.
|
||||
|
||||
Project-internal docs are largely in Chinese (`AGENTS.md`, `README.md`); match that when editing them.
|
||||
|
||||
## Build & run
|
||||
|
||||
The wrapper script `./build-and-run.sh` is the normal entry point (builds frontend + backend, then runs). Key flags:
|
||||
|
||||
```bash
|
||||
./build-and-run.sh --check # cargo check only — fastest verification, no frontend, no run
|
||||
./build-and-run.sh # dev profile, reuse frontend cache, compile + start
|
||||
./build-and-run.sh --backend-only # recompile/restart backend, skip Vite
|
||||
./build-and-run.sh --fast-release # near-prod performance, faster than --release
|
||||
./build-and-run.sh --production # build + embed frontend/Workflow UI into binary -> dist/production/
|
||||
```
|
||||
|
||||
Cargo profiles: `dev` (default), `release` (LTO, slow), `fast-release` (opt-level 2, no LTO — use for staging verification). Direct cargo also works: `cargo run --release`, `cargo check`.
|
||||
|
||||
Config comes from `config.toml` plus env overrides via figment. Env vars use the `BOOSAPI_` prefix with `__` as the nesting separator, e.g. `BOOSAPI_SERVER__PORT=4000`, `BOOSAPI_AUTH__AUTH_TOKEN=...`, `BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=...`. Prefer env vars for all secrets.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo test # Rust tests (rstest, wiremock, axum-test, tracing-test)
|
||||
cargo test <name> # single Rust test by name substring
|
||||
npm test # web tests: vitest run --root src/web
|
||||
npm run test:watch # vitest watch
|
||||
npx vitest run src/web/api.test.ts # single web test file
|
||||
npm run typecheck # tsc for src/web (app + tests)
|
||||
```
|
||||
|
||||
Rust tests live in `#[cfg(test)]` modules alongside source. Web tests are `src/web/**/*.test.ts(x)`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Proxy core (`src/proxy/`) — the heart of the system
|
||||
|
||||
Request flow for `/v1/*`: **downstream key auth → token route resolution → channel selection → upstream call (with failover) → protocol transform → response**.
|
||||
|
||||
- `token_router/` — `TokenRouter` resolves a requested model name to candidate upstream channels using DB-backed route rows + regex patterns, with a short (2s) in-process cache. Call `invalidate_route_cache()` after mutating routes/channels. Failure cooldown per channel is configurable.
|
||||
- `conductor/` — `ProxyConductor::execute` drives attempt/failover loop (max 5 attempts). It is decoupled from the DB via injected closures (`select_channel`, `select_next_channel`, `record_success`, `record_failure`, optional `refresh_auth`). `retry_policy.rs` classifies failures into actions: failover, retry-same-channel, refresh-auth, or terminal.
|
||||
- `executors/`, `providers/`, `surfaces/`, `capabilities/`, `cli_profiles/` — execution backends and provider/capability metadata.
|
||||
|
||||
### Protocol transformers (`src/transformers/`)
|
||||
|
||||
`openai/`, `anthropic/`, `gemini/` convert between client-facing protocol and upstream protocol around a `canonical/` intermediate form. When adding model/protocol support, work through the canonical layer rather than point-to-point conversions.
|
||||
|
||||
### Routes (`src/routes/`)
|
||||
|
||||
`mod.rs` assembles the axum router by `.merge()`-ing each module's `routes()` and serves the SPA as a fallback (embedded via `include_dir` in production, or from `dist/web` on disk). `admin/` is the management API; `proxy/` is the OpenAI-compatible entry; `comfyui/`, `comfyui_agent.rs`, `workflow_ui.rs`, `digital_human.rs`, `money_printer.rs`, `picture_agent.rs` are the ComfyUI/creative surfaces.
|
||||
|
||||
### Services (`src/services/`)
|
||||
|
||||
Background and cross-cutting logic: `billing/`, `payment.rs`, `cron/`, `notification/`, `runtime_auth.rs`, `builtin_keys.rs`, `comfyui_process.rs`, `route_rebuild.rs`. Shared `AppState` (in `src/lib.rs`) is cloned into handlers and holds config, DB pool, HTTP client, and these service handles.
|
||||
|
||||
### Database
|
||||
|
||||
**SQLite is the only runtime path** — `AppState.db` is `Arc<SqlitePool>`, and all runtime queries target SQLite. The Postgres dependency and `boosapi migrate-sqlite-to-postgres` subcommand exist only to copy/migrate schema+data into Postgres; full Postgres runtime query compatibility is not done. Do not assume queries run against Postgres. Migrations live in `migrations/` (SQLite); `db/schema_compat.rs` applies runtime compatibility fixups after migrations.
|
||||
|
||||
## ComfyUI Agent (local-mode contract)
|
||||
|
||||
The ComfyUI Agent defaults to **local mode**: it calls this service's own `/v1/*` routes using a bootstrapped built-in downstream key (`name='comfyui-agent' AND user_id IS NULL`, format `sk-comfyui-{uuid_hex}`, mirrored into `settings.builtin_key.comfyui_agent.secret`).
|
||||
|
||||
- Local (default): `upstream_base = http://{host}/v1` (host `0.0.0.0`/`::` rewritten to `127.0.0.1`); `/api/comfyui-agent/status` returns `mode: "local"`.
|
||||
- Fallback-remote: set `BOOSAPI_UPSTREAM_BASE` (non-empty) to use an external upstream; `BOOSAPI_UPSTREAM_KEY` falls back to the built-in key if unset.
|
||||
- The built-in key is **protected**: admin list masks it (`••••<last4>`), delete/disable return 403, and `builtin_key.*` settings are hidden from list / 403 on PUT. Startup `ensure_comfyui_agent_key` is idempotent (regenerates if missing, re-enables if disabled, keeps settings ↔ downstream_api_keys in sync).
|
||||
|
||||
Workflow backend modes (`[comfyui] backend`): `cloud` (Comfy Cloud, default), `runninghub`, `external` (your own ComfyUI), `boosapi` (built-in Rust workflow/API node executor), `docker` (legacy). Workflow UI has separate `cloud` and `external/runninghub` frontend asset bundles — install with `npm run workflow-ui:install:cloud` / `:external`.
|
||||
|
||||
## Frontend (two separate apps)
|
||||
|
||||
- `src/web/` — management console, React 18 + Vite, built to `dist/web/` and served by the Rust backend. `npm run dev` for HMR, `npm run build:web` to build.
|
||||
- `website/` — standalone marketing site, **Next.js 16 App Router + React 19**, its own package. Use `npm run website:dev` / `website:build` / `website:typecheck` (these proxy to `npm --prefix website`).
|
||||
|
||||
### Auth/session conventions (src/web)
|
||||
|
||||
- Session state goes through `authSession.ts`: `commitSession / clearSession / getSessionToken`, bucketed by audience `'admin' | 'user'`. The old `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession` family is `@deprecated` — do not use in new code.
|
||||
- Login pages are split under `pages/auth/`: `LoginShell` (shared shell) + `AdminLoginPage` (token / account modes) + `UserLoginPage`. OAuth entries render via `OAuthSlot` as placeholders only — do not wire onClick/navigation there.
|
||||
- Login error text is normalized in `loginError.ts` via `resolveLoginErrorMessage(status, raw)` — add new login errors there, not inline.
|
||||
|
||||
## Shared TS/Rust contracts (`src/shared/`)
|
||||
|
||||
This directory holds logic mirrored between Rust (`.rs`) and TypeScript (`.ts`/`.js`) — e.g. `tokenRouteContract`, `tokenRoutePatterns`, `modelBrand`, `platformIdentity`, `proxyLogMeta`, `siteInitializationPresets`. When changing a routing/identity contract, keep the Rust and TS sides in sync.
|
||||
Generated
+37
@@ -390,6 +390,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.8.9",
|
||||
"axum-test",
|
||||
"base64",
|
||||
@@ -407,6 +408,7 @@ dependencies = [
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"once_cell",
|
||||
"open-agent-sdk",
|
||||
"parking_lot",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -427,6 +429,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
"url",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -1824,6 +1827,31 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open-agent-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"glob",
|
||||
"pin-project-lite",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.80"
|
||||
@@ -3810,6 +3838,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
||||
@@ -27,6 +27,7 @@ sqlx = { version = "0.8", features = [
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
||||
url = "2"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
||||
|
||||
# Serialization
|
||||
@@ -77,6 +78,12 @@ hex = "0.4"
|
||||
include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
|
||||
# Embedded AI agent engine (MIT, vendored as a path dependency). Provides the
|
||||
# agentic loop, built-in tools, permission callback and cost tracking that the
|
||||
# Agent Workbench (src/routes/agent.rs) drives through BoosAPI's own provider.
|
||||
open-agent-sdk = { path = "../open-agent-sdk-rust" }
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.24"
|
||||
wiremock = "0.6"
|
||||
|
||||
BIN
Binary file not shown.
+35
-35
@@ -1,3 +1,6 @@
|
||||
# 必填项保留;空字符串字段、有默认值的字段、与凭证相关的内容都从 toml 移走,
|
||||
# 改用环境变量(BOOSAPI_<SECTION>__<KEY>)或后台 admin 界面写入数据库。
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 4000
|
||||
@@ -8,13 +11,9 @@ cors_origins = ["*"]
|
||||
url = "sqlite:boosapi.db?mode=rwc"
|
||||
max_connections = 10
|
||||
ssl = false
|
||||
# PostgreSQL preparation example:
|
||||
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
|
||||
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
|
||||
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
|
||||
# is completed in a follow-up SQL dialect pass.
|
||||
|
||||
[auth]
|
||||
# 改成生产值前请先改 jwt_secret / auth_token / proxy_token,避免使用默认值。
|
||||
jwt_secret = "boosapi-dev-secret-change-in-production"
|
||||
jwt_expiry_hours = 72
|
||||
auth_token = "boos4721"
|
||||
@@ -25,15 +24,11 @@ upstream_timeout_secs = 300
|
||||
first_byte_timeout_secs = 60
|
||||
max_retries = 3
|
||||
proxy_log_retention_days = 30
|
||||
# Optional proxy for external template galleries/workflow template assets.
|
||||
# Supports http://, https:// and socks5:// proxy URLs.
|
||||
# template_proxy_url = "http://127.0.0.1:7890"
|
||||
|
||||
[payment]
|
||||
enabled = false
|
||||
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
||||
# Example: "https://tybossai.com"
|
||||
public_base_url = ""
|
||||
enabled = true
|
||||
# provider: "comfy_cloud" | "stripe"
|
||||
provider = "comfy_cloud"
|
||||
product_name_prefix = "BossAI 账户充值"
|
||||
currency = "usd"
|
||||
min_amount_usd = 1.0
|
||||
@@ -42,35 +37,40 @@ order_timeout_minutes = 30
|
||||
max_pending_orders = 3
|
||||
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||
# Prefer environment variables for secrets:
|
||||
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
||||
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
stripe_secret_key = ""
|
||||
stripe_webhook_secret = ""
|
||||
comfy_customer_api_base_url = "https://api.comfy.org"
|
||||
# 凭证统一走环境变量或后台「支付管理」写入;不要写在这里:
|
||||
# BOOSAPI_PAYMENT__PUBLIC_BASE_URL
|
||||
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET
|
||||
# BOOSAPI_PAYMENT__COMFY_CUSTOMER_TOKEN / COMFY_CUSTOMER_TOKEN_ENDPOINT
|
||||
# BOOSAPI_PAYMENT__COMFY_FIREBASE_API_KEY / COMFY_OAUTH_REFRESH_TOKEN
|
||||
# BOOSAPI_PAYMENT__COMFY_OAUTH_CLIENT_ID / COMFY_OAUTH_CLIENT_SECRET
|
||||
# BOOSAPI_PAYMENT__COMFY_CHECKOUT_ENDPOINT
|
||||
|
||||
[comfyui]
|
||||
enabled = false
|
||||
# backend: "boosapi" | "cloud" | "runninghub" | "external" | "docker"
|
||||
backend = "cloud"
|
||||
python_bin = "python3"
|
||||
port = 8188
|
||||
extra_args = []
|
||||
max_restarts = 5
|
||||
# backend modes:
|
||||
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
|
||||
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
|
||||
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
|
||||
# - "external": proxy to your own full ComfyUI server.
|
||||
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
|
||||
backend = "cloud"
|
||||
# Native Comfy task costs are charged against the user's downstream API Key
|
||||
# only after successful output archival. Keep 0 while pricing is not finalized.
|
||||
money_printer_video_cost = 0.0
|
||||
digital_human_video_cost = 0.0
|
||||
# docker_image = "boosapi-comfyui:multi-tenant"
|
||||
# container_name = "boosapi-comfyui-workflow"
|
||||
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
|
||||
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
|
||||
# RunningHub proxy URL already contains the API key. Keep this empty unless
|
||||
# you switch to a backend that requires Authorization / X-API-Key headers.
|
||||
base_url = "https://cloud.comfy.org"
|
||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
||||
agent_model = "gpt-5.5"
|
||||
agent_model = "deepseek-v4-flash"
|
||||
|
||||
[pixelle_video]
|
||||
enabled = true
|
||||
# Pixelle-Video 单独启动:
|
||||
# git clone https://github.com/AIDC-AI/Pixelle-Video /opt/Pixelle-Video
|
||||
# cd /opt/Pixelle-Video && uv run python api/app.py --host 127.0.0.1 --port 18000
|
||||
api_base_url = "http://127.0.0.1:18000"
|
||||
frame_template = "1080x1920/image_default.html"
|
||||
media_workflow = "selfhost/video_wan2.1_fusionx.json"
|
||||
tts_workflow = "selfhost/tts_edge.json"
|
||||
runninghub_media_workflow = "runninghub/video_wan2.1_fusionx.json"
|
||||
runninghub_tts_workflow = "runninghub/tts_edge.json"
|
||||
mode = "fixed"
|
||||
n_scenes = 5
|
||||
video_fps = 30
|
||||
poll_interval_secs = 5
|
||||
timeout_secs = 900
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# workflow_ui/mod.rs 续拆(6 子模块)设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
上一轮已把 `workflow_ui.rs`(6181 行)拆成 8 模块,但 `mod.rs` 仍 4095 行——一批
|
||||
未在上轮清单内的函数(native proxy / repair 链 / bootstrap 注入 / run 转换 / ws / 模型注入)
|
||||
留在了 mod.rs。本轮继续把这 6 组抽出,让 mod.rs 瘦到 ~1000-1200 行(只剩 routes()
|
||||
+ 共享 struct + 小工具)。
|
||||
|
||||
延续上轮纪律:**纯机械移动,函数体一字不改**,只调可见性 + use,编译器 + 5 测试验证。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 续拆 workflow_ui/mod.rs 残留。
|
||||
2. 拆成 6 子模块(见下表)。
|
||||
3. 纯机械移动,零逻辑改动。
|
||||
4. 安全:每抽一组即 cargo build -j 2 + 5 测试保持;末尾各文件行数核对。
|
||||
|
||||
## 子模块划分
|
||||
|
||||
| 子模块 | 函数组 |
|
||||
|--------|--------|
|
||||
| `model_inject.rs` | comfy_combo_input, inject_model_choices, is_boosapi_extension_node_type, workflow_node_defs_with_models, inject_boosapi_object_info, builtin_model_folders, inject_boosapi_model_folders, workflow_model_folder_payload, native_model_folder_payload_or_fallback, model_payload_items_from_names, normalize_model_item_name, workflow_model_display_aliases, model_payload_item_with_name, merge_model_payload_items, normalize_model_folder_payload |
|
||||
| `repair.rs` | inject_native_prompt_cloud_api_key, repair_resize_image_mask_nodes(+_without_workflow), repair_text_generate_ltx2_prompt_nodes, repair_dynamic_combo_nodes(+ dynamic_combo_* 全部 helper), repair_wan2_api_nodes(+ wan2_* / is_wan2_* helper), collect_workflow_nodes, prompt_subgraph_node_id, id_to_prompt_key |
|
||||
| `native_proxy.rs` | native_workflow_enabled/base/http_path/request/json/json_cached/response/view_response/video_response/file_response/raw_response/api_proxy, archive_native_workflow_asset, repair_native_prompt_body_value, workflow_cache_kind_from_key, workflow_cache_status, upload_image(+_to_local_backend/_to_native_backend), upload_mask, timestamp_ms |
|
||||
| `run_convert.rs` | workflow_run_to_history/_task/_queue_task/_prompt_tuple, run_status_messages, run_outputs_for_comfy, node_output_for_comfy, comfy_media_items, normalize_comfy_media_item, workflow_text_output, workflow_graph_to_comfy_workflow, workflow_timestamp_ms |
|
||||
| `ws.rs` | workflow_session_sender, publish_workflow_ws_event, workflow_node_label, workflow_progress_callback_with_run_sender, publish_run_events, workflow_ws, native_workflow_ws_bridge |
|
||||
| `bootstrap.rs` | inject_bootstrap, workflow_secret_cleanup_bootstrap, inject_external_bootstrap, inject_enhanced_bootstrap |
|
||||
|
||||
> 留 mod.rs:routes()、共享 struct(WorkflowCaller/CachedJson/各 Query/Payload)、跨多模块小工具
|
||||
> (safe_view_path/user_home/guess_mime_from_path/parse_boolish/auth_caller_optional/
|
||||
> workflow_context/parse_workflow_cookie/build_query_string/simple_urlencode/各 workflow_widget_*
|
||||
> validation helper 等)、experiment_models/view_metadata/custom_nodes_i18n/internal_* 等零散 handler。
|
||||
> 实现时按实际依赖判定:被多组共用的留 mod.rs `pub(super)`。
|
||||
|
||||
## 安全策略
|
||||
|
||||
同上轮:每抽一组 → `cargo build -j 2`(编译器是主安全网,纯移动)→ `cargo test -p boosapi
|
||||
--lib routes::workflow_ui`(5 测试基线不减)→ 提交。跨模块依赖的私有符号按编译报错逐个
|
||||
改 `pub(super)`。子模块内部 helper 也 `pub(super)` 或保持模块私有(看是否跨文件用)。
|
||||
|
||||
## 测试
|
||||
|
||||
编译器逐组验证;5 个现有测试全程保持;末尾核对 8+6 个子文件行数,mod.rs 应 <1500。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改逻辑、不改路由、不动 handler 行为。
|
||||
- 不拆其他巨文件(oauth/payment/digital_human 各自后续循环)。
|
||||
- 不顺手优化函数实现。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。每抽 1 组 + 编译通过即提交。中文 commit。
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Agent Workbench sessions: per-session token / cost accounting and metadata.
|
||||
--
|
||||
-- One row per agent conversation session. Token counts and cost accumulate
|
||||
-- across the turns of a session; `updated_at` advances on every `/api/agent/chat`
|
||||
-- round-trip. `user_id` is nullable so admin (global-token) sessions are allowed
|
||||
-- without a user row, but every session is still isolated by its own sandbox
|
||||
-- directory keyed on `id`.
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id INTEGER,
|
||||
title TEXT,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
permission_mode TEXT NOT NULL DEFAULT 'default',
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
num_turns INTEGER NOT NULL DEFAULT 0,
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_sessions_user_id_idx
|
||||
ON agent_sessions (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_sessions_updated_at_idx
|
||||
ON agent_sessions (updated_at);
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod templates;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum CliProfileKind {
|
||||
Codex,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Client integration templates — copy-ready config snippets for pointing
|
||||
//! popular AI clients at this BoosAPI gateway.
|
||||
//!
|
||||
//! Ported from AQBot's `list_gateway_templates` (`commands/gateway.rs:665-730`).
|
||||
//! AQBot is a desktop app whose gateway listens on `localhost:8080`, so its
|
||||
//! templates hardcode that origin. BoosAPI is a server-side SaaS, so the origin
|
||||
//! is injected at request time (public domain / port) — see
|
||||
//! `routes::admin::cli_templates`.
|
||||
//!
|
||||
//! Scope guard (per the integration analysis): this module only returns config
|
||||
//! **strings + copy hints**. It deliberately does NOT rewrite any local CLI
|
||||
//! config files (AQBot's `cli_config.rs` desktop behavior) — that has no meaning
|
||||
//! on a server and is explicitly out of scope.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// A copy-ready integration snippet for one client.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct GatewayTemplate {
|
||||
/// Stable identifier (e.g. `"cursor"`).
|
||||
pub id: String,
|
||||
/// Human-facing name (e.g. `"Cursor"`).
|
||||
pub name: String,
|
||||
/// Target client slug, mirrors `id` (kept for frontend parity with AQBot).
|
||||
pub target: String,
|
||||
/// Content format hint for the UI renderer: `"json"` | `"markdown"`.
|
||||
pub format: String,
|
||||
/// The snippet itself, with the live gateway origin already substituted.
|
||||
pub content: String,
|
||||
/// Short instruction on where to put the snippet.
|
||||
pub copy_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the full template list for a given gateway `origin`
|
||||
/// (scheme + host + optional port, no trailing slash, e.g.
|
||||
/// `https://api.boos.lat`).
|
||||
///
|
||||
/// URL conventions differ per client:
|
||||
/// - OpenAI-compatible clients (Cursor, VS Code Continue, generic) want the
|
||||
/// `/v1` base (`{origin}/v1`).
|
||||
/// - Claude Code's `ANTHROPIC_BASE_URL` must be the bare origin — it appends
|
||||
/// `/v1/messages` itself, so a `/v1` suffix would double it.
|
||||
pub fn list_templates(origin: &str) -> Vec<GatewayTemplate> {
|
||||
let origin = origin.trim_end_matches('/');
|
||||
let openai_base = format!("{origin}/v1");
|
||||
|
||||
vec![
|
||||
GatewayTemplate {
|
||||
id: "cursor".to_string(),
|
||||
name: "Cursor".to_string(),
|
||||
target: "cursor".to_string(),
|
||||
format: "json".to_string(),
|
||||
content: format!(
|
||||
"{{\n \"openai.apiKey\": \"YOUR_KEY\",\n \"openai.apiBaseUrl\": \"{openai_base}\"\n}}"
|
||||
),
|
||||
copy_hint: Some("Add to Cursor User settings.json".to_string()),
|
||||
},
|
||||
GatewayTemplate {
|
||||
id: "vscode".to_string(),
|
||||
name: "VS Code Continue".to_string(),
|
||||
target: "vscode".to_string(),
|
||||
format: "json".to_string(),
|
||||
content: format!(
|
||||
"{{\n \"models\": [{{\n \"title\": \"BoosAPI Gateway\",\n \"provider\": \"openai\",\n \"apiBase\": \"{openai_base}\",\n \"apiKey\": \"YOUR_KEY\"\n }}]\n}}"
|
||||
),
|
||||
copy_hint: Some("Add to ~/.continue/config.json".to_string()),
|
||||
},
|
||||
GatewayTemplate {
|
||||
id: "claude_code".to_string(),
|
||||
name: "Claude Code".to_string(),
|
||||
target: "claude_code".to_string(),
|
||||
format: "markdown".to_string(),
|
||||
// Claude Code appends `/v1/messages` to ANTHROPIC_BASE_URL, so the
|
||||
// base is the bare origin (no `/v1`).
|
||||
content: format!(
|
||||
"export ANTHROPIC_AUTH_TOKEN=\"YOUR_KEY\"\nexport ANTHROPIC_BASE_URL=\"{origin}\""
|
||||
),
|
||||
copy_hint: Some("Add to your shell profile (~/.bashrc or ~/.zshrc)".to_string()),
|
||||
},
|
||||
GatewayTemplate {
|
||||
id: "openai_compatible".to_string(),
|
||||
name: "OpenAI Compatible".to_string(),
|
||||
target: "openai_compatible".to_string(),
|
||||
format: "json".to_string(),
|
||||
content: format!(
|
||||
"{{\n \"api_key\": \"YOUR_KEY\",\n \"base_url\": \"{openai_base}\",\n \"model\": \"gpt-4\"\n}}"
|
||||
),
|
||||
copy_hint: Some("Use these settings in any OpenAI-compatible client".to_string()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Look up a single template's content by id for the given `origin`.
|
||||
/// Ports AQBot's `copy_gateway_template`.
|
||||
pub fn find_template(id: &str, origin: &str) -> Option<GatewayTemplate> {
|
||||
list_templates(origin).into_iter().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_all_four_templates() {
|
||||
let templates = list_templates("https://api.boos.lat");
|
||||
let ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(ids, ["cursor", "vscode", "claude_code", "openai_compatible"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_clients_get_v1_base() {
|
||||
let templates = list_templates("https://api.boos.lat");
|
||||
for id in ["cursor", "vscode", "openai_compatible"] {
|
||||
let t = templates.iter().find(|t| t.id == id).unwrap();
|
||||
assert!(
|
||||
t.content.contains("https://api.boos.lat/v1"),
|
||||
"{id} should embed the /v1 base, got: {}",
|
||||
t.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_base_has_no_v1_suffix() {
|
||||
// Claude Code appends /v1/messages itself; a /v1 base would double it.
|
||||
let t = find_template("claude_code", "https://api.boos.lat").unwrap();
|
||||
assert!(t.content.contains("ANTHROPIC_BASE_URL=\"https://api.boos.lat\""));
|
||||
assert!(!t.content.contains("api.boos.lat/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_in_origin_is_normalized() {
|
||||
let t = find_template("cursor", "https://api.boos.lat/").unwrap();
|
||||
assert!(t.content.contains("https://api.boos.lat/v1"));
|
||||
assert!(!t.content.contains("//v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_id_returns_none() {
|
||||
assert!(find_template("nope", "https://api.boos.lat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_serialize_to_expected_shape() {
|
||||
let t = find_template("cursor", "http://localhost:4000").unwrap();
|
||||
let value = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!(value["id"], "cursor");
|
||||
assert_eq!(value["format"], "json");
|
||||
assert!(value["content"].as_str().unwrap().contains("localhost:4000/v1"));
|
||||
assert!(value["copy_hint"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -1246,3 +1246,215 @@ pub async fn execute_image_edit(
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
// ── Gemini native passthrough ───────────────────────────────────────────
|
||||
//
|
||||
// Gemini's native protocol carries the model in the URL path
|
||||
// (`/v1beta/models/{model}:{op}`) and the API key in the `?key=` query, so the
|
||||
// request body needs no rewrite — we forward it verbatim. This mirrors AQBot's
|
||||
// `handle_native_request`, which deliberately skips `rewrite_body_model` for
|
||||
// Gemini, and BoosAPI's own `/v1/responses` passthrough.
|
||||
|
||||
/// Normalize an upstream base into a Gemini `v1beta` root. Accepts bases that
|
||||
/// already end in `/v1beta` (or contain `/v1beta/`) and leaves them as-is;
|
||||
/// otherwise appends `/v1beta`. Trailing slashes are trimmed.
|
||||
fn gemini_base_url(base_url: &str) -> String {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
if trimmed.ends_with("/v1beta") || trimmed.contains("/v1beta/") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}/v1beta")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `{base}/v1beta/models/{model}:{op}?key={api_key}[&{extra_query}]`.
|
||||
/// `extra_query` carries client query params already filtered (e.g. `alt=sse`),
|
||||
/// without a leading `?`/`&`.
|
||||
fn gemini_operation_url(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
operation: &str,
|
||||
api_key: &str,
|
||||
extra_query: Option<&str>,
|
||||
) -> String {
|
||||
let base = gemini_base_url(base_url);
|
||||
let model = model.trim().trim_start_matches('/');
|
||||
let model = model.strip_prefix("models/").unwrap_or(model);
|
||||
let mut url = format!("{base}/models/{model}:{operation}");
|
||||
let mut sep = '?';
|
||||
if !api_key.is_empty() {
|
||||
url.push(sep);
|
||||
url.push_str("key=");
|
||||
url.push_str(api_key);
|
||||
sep = '&';
|
||||
}
|
||||
if let Some(query) = extra_query.filter(|q| !q.is_empty()) {
|
||||
url.push(sep);
|
||||
url.push_str(query);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Execute a non-streaming Gemini operation (`generateContent` / `countTokens`)
|
||||
/// and return the upstream JSON verbatim.
|
||||
pub async fn execute_gemini_operation(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
operation: &str,
|
||||
body: Value,
|
||||
extra_query: Option<&str>,
|
||||
) -> AppResult<Value> {
|
||||
let client = state.http.clone();
|
||||
let upstream_url =
|
||||
gemini_operation_url(&route.base_url, &route.model, operation, &route.api_key, extra_query);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
parse_upstream_json(&text, &upstream_url, status)
|
||||
}
|
||||
|
||||
/// Execute a streaming Gemini operation (`streamGenerateContent`) and return the
|
||||
/// live upstream response for SSE passthrough. `alt=sse` is enforced in the
|
||||
/// query so the upstream emits `data:`-framed events the accumulator can parse.
|
||||
pub async fn execute_gemini_stream_response(
|
||||
state: AppState,
|
||||
route: UpstreamRoute,
|
||||
operation: &str,
|
||||
body: Value,
|
||||
extra_query: Option<&str>,
|
||||
) -> AppResult<reqwest::Response> {
|
||||
let client = state.http.clone();
|
||||
let query = merge_alt_sse(extra_query);
|
||||
let upstream_url = gemini_operation_url(
|
||||
&route.base_url,
|
||||
&route.model,
|
||||
operation,
|
||||
&route.api_key,
|
||||
Some(&query),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "unknown error".to_string());
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Ensure `alt=sse` is present in the extra query (Gemini streams plain JSON
|
||||
/// arrays without it). Preserves any other client-supplied params.
|
||||
fn merge_alt_sse(extra_query: Option<&str>) -> String {
|
||||
match extra_query.filter(|q| !q.is_empty()) {
|
||||
Some(q) if q.split('&').any(|p| p == "alt=sse" || p.starts_with("alt=")) => q.to_string(),
|
||||
Some(q) => format!("{q}&alt=sse"),
|
||||
None => "alt=sse".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gemini_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_url_appends_v1beta_when_missing() {
|
||||
assert_eq!(
|
||||
gemini_base_url("https://generativelanguage.googleapis.com"),
|
||||
"https://generativelanguage.googleapis.com/v1beta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_preserves_existing_v1beta() {
|
||||
assert_eq!(
|
||||
gemini_base_url("https://generativelanguage.googleapis.com/v1beta/"),
|
||||
"https://generativelanguage.googleapis.com/v1beta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_url_embeds_model_op_and_key() {
|
||||
let url = gemini_operation_url(
|
||||
"https://generativelanguage.googleapis.com",
|
||||
"gemini-2.0-flash",
|
||||
"generateContent",
|
||||
"secret",
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_url_strips_models_prefix_and_appends_query() {
|
||||
let url = gemini_operation_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"models/gemini-1.5-pro",
|
||||
"streamGenerateContent",
|
||||
"k",
|
||||
Some("alt=sse"),
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:streamGenerateContent?key=k&alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_url_without_key_uses_question_mark_for_query() {
|
||||
let url = gemini_operation_url(
|
||||
"https://example.test/v1beta",
|
||||
"gemini-x",
|
||||
"countTokens",
|
||||
"",
|
||||
Some("foo=bar"),
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://example.test/v1beta/models/gemini-x:countTokens?foo=bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_alt_sse_variants() {
|
||||
assert_eq!(merge_alt_sse(None), "alt=sse");
|
||||
assert_eq!(merge_alt_sse(Some("")), "alt=sse");
|
||||
assert_eq!(merge_alt_sse(Some("foo=bar")), "foo=bar&alt=sse");
|
||||
// already present → unchanged
|
||||
assert_eq!(merge_alt_sse(Some("alt=sse")), "alt=sse");
|
||||
assert_eq!(merge_alt_sse(Some("alt=json")), "alt=json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! `GET /api/cli-profiles/templates` — copy-ready client integration snippets.
|
||||
//!
|
||||
//! Returns config blobs (Cursor / VS Code Continue / Claude Code /
|
||||
//! OpenAI-compatible) with this gateway's live origin already substituted, so
|
||||
//! the admin UI can render "copy & paste" cards. The actual snippet content
|
||||
//! lives in `proxy::cli_profiles::templates`.
|
||||
//!
|
||||
//! Origin resolution priority:
|
||||
//! 1. `?base_url=` query override (operator-supplied public URL)
|
||||
//! 2. `X-Forwarded-Proto` + `Host` headers (the address the client actually
|
||||
//! reached us on — correct behind a TLS-terminating reverse proxy)
|
||||
//! 3. `Host` header alone (scheme inferred as http)
|
||||
//! 4. config `server.host:port` fallback (`0.0.0.0`/`::` rewritten to
|
||||
//! `127.0.0.1`, matching the comfyui-agent local-base convention)
|
||||
|
||||
use crate::{proxy::cli_profiles::templates, AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TemplatesQuery {
|
||||
/// Optional explicit public base URL, e.g. `https://api.boos.lat`.
|
||||
/// A trailing `/v1` (or `/`) is stripped so callers can pass either form.
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the gateway origin (scheme + host[:port], no trailing slash) to
|
||||
/// embed in the templates.
|
||||
fn resolve_origin(state: &AppState, headers: &HeaderMap, query: &TemplatesQuery) -> String {
|
||||
// 1. Explicit override wins. Normalize away a trailing `/v1` or `/`.
|
||||
if let Some(raw) = query.base_url.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
let trimmed = raw.trim_end_matches('/');
|
||||
let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed);
|
||||
return trimmed.trim_end_matches('/').to_string();
|
||||
}
|
||||
|
||||
// 2/3. Derive from the inbound request headers.
|
||||
if let Some(host) = header_str(headers, "host").filter(|h| !h.is_empty()) {
|
||||
let scheme = header_str(headers, "x-forwarded-proto")
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
return format!("{scheme}://{host}");
|
||||
}
|
||||
|
||||
// 4. Config fallback. Mirrors comfyui_agent::local_base host rewrite.
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
state.config.server.host.as_str()
|
||||
};
|
||||
format!("http://{host}:{}", state.config.server.port)
|
||||
}
|
||||
|
||||
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim)
|
||||
}
|
||||
|
||||
/// `GET /api/cli-profiles/templates` — list copy-ready integration snippets.
|
||||
pub async fn list_templates(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<TemplatesQuery>,
|
||||
) -> Json<Value> {
|
||||
let origin = resolve_origin(&state, &headers, &query);
|
||||
let templates = templates::list_templates(&origin);
|
||||
Json(json!({
|
||||
"data": {
|
||||
"origin": origin,
|
||||
"templates": templates,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
map.insert(
|
||||
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn query(base: Option<&str>) -> TemplatesQuery {
|
||||
TemplatesQuery {
|
||||
base_url: base.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// resolve_origin doesn't touch the DB, so build a config-only state stub
|
||||
// via the existing admin test helper would be heavy; instead these tests
|
||||
// cover the header/override branches that don't need `state` fallback by
|
||||
// exercising resolve_origin's pure header logic through a tiny shim.
|
||||
|
||||
#[test]
|
||||
fn override_strips_v1_and_trailing_slash() {
|
||||
// The override branch returns before touching `state`, so a null-ish
|
||||
// pointer is never dereferenced — we validate via the helper directly.
|
||||
let q = query(Some("https://api.boos.lat/v1/"));
|
||||
// Reconstruct the override-only logic the function applies.
|
||||
let raw = q.base_url.as_ref().unwrap().trim();
|
||||
let trimmed = raw.trim_end_matches('/');
|
||||
let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed);
|
||||
assert_eq!(trimmed.trim_end_matches('/'), "https://api.boos.lat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_header_with_forwarded_proto_builds_https_origin() {
|
||||
let h = headers(&[("host", "api.boos.lat"), ("x-forwarded-proto", "https")]);
|
||||
let scheme = header_str(&h, "x-forwarded-proto").unwrap();
|
||||
let host = header_str(&h, "host").unwrap();
|
||||
assert_eq!(format!("{scheme}://{host}"), "https://api.boos.lat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_proto_takes_first_value_when_chained() {
|
||||
let h = headers(&[("x-forwarded-proto", "https, http")]);
|
||||
let scheme = header_str(&h, "x-forwarded-proto")
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||
.unwrap();
|
||||
assert_eq!(scheme, "https");
|
||||
}
|
||||
}
|
||||
@@ -2955,8 +2955,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -412,8 +412,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -17,6 +17,7 @@ use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
mod cli_templates;
|
||||
mod compat;
|
||||
mod downstream_keys;
|
||||
mod model_candidates;
|
||||
@@ -175,6 +176,11 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
get(models_marketplace::get_marketplace),
|
||||
);
|
||||
|
||||
let cli_templates_routes = Router::new().route(
|
||||
"/api/cli-profiles/templates",
|
||||
get(cli_templates::list_templates),
|
||||
);
|
||||
|
||||
let users_routes = Router::new()
|
||||
.route(
|
||||
"/api/admin/users",
|
||||
@@ -226,6 +232,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.merge(users_routes)
|
||||
.merge(model_candidates_routes)
|
||||
.merge(models_marketplace_routes)
|
||||
.merge(cli_templates_routes)
|
||||
.merge(account_tokens::routes())
|
||||
.merge(compat::routes())
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Agent Workbench HTTP surface.
|
||||
//!
|
||||
//! A general-purpose, server-side AI agent exposed to the SPA over SSE. The
|
||||
//! agentic loop comes from the `open-agent-sdk` crate; every LLM call is routed
|
||||
//! back through BoosAPI's own OpenAI-compatible `/v1` endpoint using the
|
||||
//! built-in downstream key, so agent usage flows through the same aggregation
|
||||
//! and accounting pipeline as any other consumer (and can use any aggregated
|
||||
//! model).
|
||||
//!
|
||||
//! Routes (all require a Bearer token — admin token, proxy token, or a user JWT):
|
||||
//! - `POST /api/agent/chat` Stream a turn as SSE (text / tool_use /
|
||||
//! tool_result / approval_request / result / error / done).
|
||||
//! - `POST /api/agent/approve` Resolve a pending tool approval.
|
||||
//! - `GET /api/agent/status` Engine status + a session's accounting.
|
||||
//!
|
||||
//! ## Safety
|
||||
//! Each session runs inside an isolated sandbox directory
|
||||
//! (`data/agent_sessions/{id}/`). The SDK permission callback ([`build_can_use_tool`])
|
||||
//! enforces two gates on every tool: (1) path arguments must stay within the
|
||||
//! sandbox, and (2) writes/executes require explicit user approval unless the
|
||||
//! session mode opts out. The default mode requires approval for anything that
|
||||
//! mutates state or runs a command.
|
||||
|
||||
use crate::auth::jwt::validate_jwt;
|
||||
use crate::services::agent_workbench::{
|
||||
approval::{self, ApprovalOutcome, SettledState},
|
||||
bash_sandbox::SandboxedBashTool,
|
||||
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||
sandbox,
|
||||
session_store::{self, UsageDelta},
|
||||
};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::sse::{Event, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use open_agent_sdk::api::openai::OpenAIProvider;
|
||||
use open_agent_sdk::types::{ContentBlock, PermissionDecision};
|
||||
use open_agent_sdk::{Agent, AgentOptions, LLMProvider, SDKMessage};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Max agentic turns per chat request (bounds runaway loops / cost).
|
||||
const MAX_TURNS: u32 = 20;
|
||||
/// How long a tool approval waits for the user before auto-denying.
|
||||
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// Body limit for a chat request (images/attachments can be large).
|
||||
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
const SYSTEM_PROMPT: &str = "You are BoosAPI's built-in engineering agent. You can read and write files \
|
||||
and run shell commands, but only inside the current working directory, which is an \
|
||||
isolated sandbox for this session. Be concise. Explain what you are about to do before \
|
||||
using a tool that writes files or runs commands. Never attempt to access paths outside \
|
||||
the working directory.";
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/agent/chat", post(chat_handler))
|
||||
.route("/api/agent/approve", post(approve_handler))
|
||||
.route("/api/agent/status", get(status_handler))
|
||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatRequest {
|
||||
/// The user's prompt for this turn.
|
||||
message: String,
|
||||
/// Existing session id to continue; a new one is minted when absent.
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
/// Aggregated model id; falls back to the configured agent model.
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
/// Permission mode: "default" | "accept_edits" | "full_access".
|
||||
#[serde(default)]
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApproveRequest {
|
||||
request_id: String,
|
||||
approved: bool,
|
||||
}
|
||||
|
||||
/// Resolved caller identity for an agent request.
|
||||
#[derive(Debug, Clone)]
|
||||
struct AgentAuth {
|
||||
/// `None` for admin/global-token callers; `Some(id)` for user JWTs.
|
||||
user_id: Option<i64>,
|
||||
}
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Authenticate the caller. Admin token / proxy token map to an admin
|
||||
/// (user-less) session; a valid user/admin JWT maps to that user's id.
|
||||
async fn resolve_auth(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<AgentAuth, (StatusCode, Json<Value>)> {
|
||||
let unauthorized = || {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
)
|
||||
};
|
||||
|
||||
let token = extract_bearer_token(headers).ok_or_else(unauthorized)?;
|
||||
|
||||
if token == state.config.auth.auth_token || token == state.runtime_proxy_token() {
|
||||
return Ok(AgentAuth { user_id: None });
|
||||
}
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, token).map_err(|_| unauthorized())?;
|
||||
if claims.role != "admin" && claims.role != "user" {
|
||||
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": "Forbidden" }))));
|
||||
}
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid user id" }))))?;
|
||||
Ok(AgentAuth { user_id: Some(user_id) })
|
||||
}
|
||||
|
||||
/// Send one SSE event; returns false if the receiver (client) has gone away.
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
}
|
||||
|
||||
/// Resolve BoosAPI's own base URL (no `/v1` suffix — the OpenAI provider appends
|
||||
/// `/v1/chat/completions`).
|
||||
fn boosapi_base(state: &AppState) -> String {
|
||||
let host = match state.config.server.host.as_str() {
|
||||
"0.0.0.0" | "::" => "127.0.0.1",
|
||||
other => other,
|
||||
};
|
||||
format!("http://{}:{}", host, state.config.server.port)
|
||||
}
|
||||
|
||||
/// Build the SDK permission callback that enforces sandbox path safety and the
|
||||
/// approval flow. The callback runs inside the agent loop for every tool call.
|
||||
///
|
||||
/// Order of checks:
|
||||
/// 1. Sandbox: any path argument that escapes `cwd` is hard-denied immediately.
|
||||
/// 2. Risk + mode: read-only (and, per mode, writes) auto-allow; otherwise emit
|
||||
/// an `approval_request` SSE event and block until the user responds (or the
|
||||
/// approval times out, which denies).
|
||||
fn build_can_use_tool(
|
||||
sse_tx: UnboundedSender<Result<Event, Infallible>>,
|
||||
cwd: std::path::PathBuf,
|
||||
mode: PermissionMode,
|
||||
) -> open_agent_sdk::types::CanUseToolFn {
|
||||
Arc::new(move |tool_name: &str, input: &Value| {
|
||||
let sse_tx = sse_tx.clone();
|
||||
let cwd = cwd.clone();
|
||||
let tool_name = tool_name.to_string();
|
||||
let input = input.clone();
|
||||
Box::pin(async move {
|
||||
// Gate 1: sandbox path safety (always enforced, even in full access).
|
||||
if let Err(reason) = sandbox::check_tool_paths(&tool_name, &input, &cwd) {
|
||||
send_sse(
|
||||
&sse_tx,
|
||||
json!({
|
||||
"type": "tool_denied",
|
||||
"data": { "name": tool_name, "reason": reason }
|
||||
}),
|
||||
);
|
||||
return PermissionDecision::Deny(reason);
|
||||
}
|
||||
|
||||
// Gate 2: risk + permission mode.
|
||||
let risk = classify_tool_risk(&tool_name);
|
||||
match decide_permission(mode, risk) {
|
||||
PermissionAction::AutoAllow => PermissionDecision::Allow,
|
||||
PermissionAction::RequireApproval => {
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
// `_guard` removes the registry entry when this future ends
|
||||
// (normal completion or drop on session abort) — no leak.
|
||||
let (entry, _guard) = approval::register(&request_id);
|
||||
send_sse(
|
||||
&sse_tx,
|
||||
json!({
|
||||
"type": "approval_request",
|
||||
"data": {
|
||||
"request_id": request_id,
|
||||
"name": tool_name,
|
||||
"input": input,
|
||||
"risk": format!("{risk:?}"),
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Single source of truth: the settled atomic state decides
|
||||
// allow vs deny. `resolve` reports back to the frontend
|
||||
// whether its CAS won, so the two can never disagree.
|
||||
let settled = approval::wait_for_decision(&entry, APPROVAL_TIMEOUT).await;
|
||||
let (decision, wire) = match settled {
|
||||
SettledState::Approved => (PermissionDecision::Allow, "approved"),
|
||||
SettledState::Denied => {
|
||||
(PermissionDecision::Deny("Denied by user".to_string()), "denied")
|
||||
}
|
||||
SettledState::TimedOut => (
|
||||
PermissionDecision::Deny("Approval timed out".to_string()),
|
||||
"timed_out",
|
||||
),
|
||||
};
|
||||
// Tell the client how this prompt actually resolved, so a
|
||||
// late click (which the /approve call already reported as
|
||||
// ok:false) can dismiss the modal with the real outcome.
|
||||
send_sse(
|
||||
&sse_tx,
|
||||
json!({
|
||||
"type": "approval_resolved",
|
||||
"data": { "request_id": request_id, "decision": wire }
|
||||
}),
|
||||
);
|
||||
decision
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ChatRequest>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, Json<Value>)> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
|
||||
if body.message.trim().is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "message is required" })),
|
||||
));
|
||||
}
|
||||
|
||||
let session_id = body
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let mode = PermissionMode::from_wire(body.permission_mode.as_deref().unwrap_or("default"));
|
||||
let model = body
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
|
||||
|
||||
// Per-session sandbox working directory.
|
||||
let cwd = match sandbox::ensure_session_dir(&session_id) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to create session sandbox: {e}") })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Persist (idempotently) the session row before streaming.
|
||||
if let Err(e) = session_store::ensure_session(
|
||||
&state.db,
|
||||
&session_id,
|
||||
auth.user_id,
|
||||
&model,
|
||||
&format!("{mode:?}").to_lowercase(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%e, "failed to ensure agent session row");
|
||||
}
|
||||
|
||||
// Route the agent's LLM calls back through BoosAPI's own aggregation using
|
||||
// the built-in downstream key.
|
||||
let base_url = boosapi_base(&state);
|
||||
let internal_key = state.builtin_keys.comfyui_agent.1.clone();
|
||||
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
let db = state.db.clone();
|
||||
let prompt = body.message;
|
||||
let session_for_event = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// First event: hand the (possibly newly minted) session id to the client.
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({ "type": "session", "data": { "session_id": session_for_event, "model": model } }),
|
||||
);
|
||||
|
||||
let provider: Arc<dyn LLMProvider> = Arc::new(OpenAIProvider::new(
|
||||
reqwest::Client::new(),
|
||||
internal_key,
|
||||
base_url,
|
||||
Default::default(),
|
||||
));
|
||||
|
||||
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
||||
|
||||
// Replace the SDK's built-in Bash (a bare subprocess) with an
|
||||
// OS-sandboxed one confined to this session's directory. Registered as
|
||||
// a custom tool, it overrides the built-in by name; review modes run it
|
||||
// under bwrap, FullAccess runs it unconfined with audit logging.
|
||||
let sandboxed_bash: Arc<dyn open_agent_sdk::types::Tool> =
|
||||
Arc::new(SandboxedBashTool::new(cwd.clone(), mode));
|
||||
|
||||
let agent = Agent::new(AgentOptions {
|
||||
provider: Some(provider),
|
||||
model: Some(model.clone()),
|
||||
cwd: Some(cwd.to_string_lossy().to_string()),
|
||||
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
||||
max_turns: Some(MAX_TURNS),
|
||||
can_use_tool: Some(can_use_tool),
|
||||
custom_tools: vec![sandboxed_bash],
|
||||
session_id: Some(session_id.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut agent = match agent {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
send_sse(&tx, json!({ "type": "error", "data": { "message": e } }));
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut rx_msg, handle) = agent.query(&prompt).await;
|
||||
|
||||
while let Some(event) = rx_msg.recv().await {
|
||||
// Stop early if the client disconnected.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
forward_sdk_event(&tx, &db, &session_id, event).await;
|
||||
}
|
||||
let _ = handle.await;
|
||||
agent.close().await;
|
||||
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
/// Translate one SDK message into SSE event(s), and persist usage on Result.
|
||||
async fn forward_sdk_event(
|
||||
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||
db: &sqlx::SqlitePool,
|
||||
session_id: &str,
|
||||
event: SDKMessage,
|
||||
) {
|
||||
match event {
|
||||
SDKMessage::Assistant { message, .. } => {
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::Text { text } if !text.is_empty() => {
|
||||
send_sse(tx, json!({ "type": "text", "data": { "text": text } }));
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
send_sse(
|
||||
tx,
|
||||
json!({ "type": "tool_use", "data": { "id": id, "name": name, "input": input } }),
|
||||
);
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } if !thinking.is_empty() => {
|
||||
send_sse(tx, json!({ "type": "thinking", "data": { "thinking": thinking } }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
SDKMessage::ToolResult { tool_use_id, tool_name, content, is_error } => {
|
||||
send_sse(
|
||||
tx,
|
||||
json!({
|
||||
"type": "tool_result",
|
||||
"data": { "tool_use_id": tool_use_id, "name": tool_name, "content": content, "is_error": is_error }
|
||||
}),
|
||||
);
|
||||
}
|
||||
SDKMessage::Result { text, usage, num_turns, cost_usd, .. } => {
|
||||
// Persist this turn's accounting (user prompt + assistant reply ≈ 2 msgs).
|
||||
let delta = UsageDelta {
|
||||
input_tokens: usage.input_tokens as i64,
|
||||
output_tokens: usage.output_tokens as i64,
|
||||
cost_usd,
|
||||
num_turns: num_turns as i64,
|
||||
};
|
||||
if let Err(e) = session_store::record_turn(db, session_id, delta, 2).await {
|
||||
tracing::warn!(%e, "failed to record agent turn usage");
|
||||
}
|
||||
send_sse(
|
||||
tx,
|
||||
json!({
|
||||
"type": "result",
|
||||
"data": {
|
||||
"text": text,
|
||||
"num_turns": num_turns,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.input_tokens + usage.output_tokens,
|
||||
"cost_usd": cost_usd,
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
SDKMessage::Error { message } => {
|
||||
send_sse(tx, json!({ "type": "error", "data": { "message": message } }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn approve_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ApproveRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Auth required, but any authenticated caller may answer the prompt for
|
||||
// their own running session (approvals are keyed by an unguessable uuid).
|
||||
resolve_auth(&headers, &state).await?;
|
||||
let outcome = if body.approved { ApprovalOutcome::Approve } else { ApprovalOutcome::Deny };
|
||||
let delivered = approval::resolve(&body.request_id, outcome);
|
||||
Ok(Json(json!({ "ok": delivered })))
|
||||
}
|
||||
|
||||
async fn status_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(q): axum::extract::Query<StatusQuery>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let session = match q.session_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(sid) => session_store::get_session(&state.db, sid, auth.user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?,
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"engine": "open-agent-sdk",
|
||||
"default_model": state.config.comfyui.agent_model,
|
||||
"session": session,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusQuery {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,9 @@ use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Credit (in USD) granted to a newly self-registered user's default API key.
|
||||
/// Default spending cap (USD) granted to a newly created user's auto-generated
|
||||
/// API key. Per the billing design (新用户默认 $1 额度), new users start with a
|
||||
/// $1 allowance on their default downstream key.
|
||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
@@ -1045,6 +1047,7 @@ mod tests {
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -126,8 +126,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -1552,8 +1552,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -580,8 +580,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -22,6 +22,7 @@ const _: &[u8] = include_bytes!(concat!(
|
||||
));
|
||||
|
||||
mod admin;
|
||||
mod agent;
|
||||
mod auth;
|
||||
mod checkin;
|
||||
mod comfy_cloud_adapter;
|
||||
@@ -57,6 +58,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(comfy_cloud_adapter::routes())
|
||||
.merge(money_printer::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(agent::routes())
|
||||
.merge(digital_human::routes())
|
||||
.merge(comfyui::routes())
|
||||
.merge(crate::workflow::routes::routes())
|
||||
|
||||
@@ -2277,8 +2277,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui,
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
+1
-1
@@ -2550,8 +2550,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
+508
-1
@@ -73,6 +73,16 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/v1/videos", post(videos_handler))
|
||||
.route("/v1/videos/{id}", get(video_task_handler))
|
||||
.route("/v1/models", get(list_models))
|
||||
// ── Gemini native inbound (v1beta) ────────────────────────────
|
||||
// Lets Gemini CLI / Google GenAI SDKs point their base URL straight
|
||||
// at this gateway. The model lives in the path and the key in the
|
||||
// `?key=` query (or x-goog-api-key header), so requests pass through
|
||||
// to a Gemini-shaped upstream without body rewriting.
|
||||
.route("/v1beta/models", get(gemini_list_models))
|
||||
.route(
|
||||
"/v1beta/models/{model_action}",
|
||||
post(gemini_model_operation),
|
||||
)
|
||||
// ── ComfyUI shim aliases ──────────────────────────────────────
|
||||
// ComfyUI's OpenAI nodes hit /proxy/openai/... paths (see
|
||||
// comfy_api_nodes/nodes_openai.py). Mounting them here as aliases
|
||||
@@ -157,6 +167,209 @@ async fn responses_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gemini native inbound handlers (v1beta) ────────────────────────────
|
||||
//
|
||||
// Gemini clients (Gemini CLI / Google GenAI SDK) carry the model in the URL
|
||||
// path (`/v1beta/models/{model}:{op}`) and the gateway key in `?key=` or the
|
||||
// `x-goog-api-key` header. We authenticate against our downstream keys, route
|
||||
// the Gemini model through the same ProxyConductor as the OpenAI endpoints, and
|
||||
// forward the body verbatim to a Gemini-shaped upstream (no body rewrite).
|
||||
|
||||
/// Gemini operations carried in the `{model}:{op}` path segment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GeminiOperation {
|
||||
GenerateContent,
|
||||
StreamGenerateContent,
|
||||
CountTokens,
|
||||
}
|
||||
|
||||
impl GeminiOperation {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GeminiOperation::GenerateContent => "generateContent",
|
||||
GeminiOperation::StreamGenerateContent => "streamGenerateContent",
|
||||
GeminiOperation::CountTokens => "countTokens",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `{model}:{operation}` path segment (e.g.
|
||||
/// `gemini-2.5-pro:streamGenerateContent` or `models/gemini-x:countTokens`).
|
||||
/// Splits on the last `:` so model names that contain none are rejected.
|
||||
fn parse_gemini_model_action(model_action: &str) -> Option<(String, GeminiOperation)> {
|
||||
let (model, action) = model_action.rsplit_once(':')?;
|
||||
let operation = match action {
|
||||
"generateContent" => GeminiOperation::GenerateContent,
|
||||
"streamGenerateContent" => GeminiOperation::StreamGenerateContent,
|
||||
"countTokens" => GeminiOperation::CountTokens,
|
||||
_ => return None,
|
||||
};
|
||||
if model.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((model.to_string(), operation))
|
||||
}
|
||||
|
||||
/// Authenticate a Gemini-native request: the key arrives in `?key=` or the
|
||||
/// `x-goog-api-key` header (Gemini convention), not `Authorization: Bearer`.
|
||||
/// Reuses the same downstream-key authorization + limits as `check_auth`.
|
||||
async fn check_gemini_auth(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
query: &std::collections::HashMap<String, String>,
|
||||
) -> Result<Option<i64>, AppError> {
|
||||
let key = query
|
||||
.get("key")
|
||||
.map(|s| s.as_str())
|
||||
.or_else(|| headers.get("x-goog-api-key").and_then(|v| v.to_str().ok()))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::Unauthorized("Missing API key (use ?key= or x-goog-api-key)".into())
|
||||
})?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
let result = authorize_downstream_token(key, &state.db, Some(&proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key } => {
|
||||
check_downstream_key_limits(&key)?;
|
||||
Ok(Some(key.id))
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the upstream query string from client params, dropping the inbound
|
||||
/// gateway `key` (the upstream key is injected by the executor) and `alt` (the
|
||||
/// streaming path enforces its own `alt=sse`). Returns `None` when nothing
|
||||
/// remains. Values are forwarded as-is: real Gemini clients only put `key`/`alt`
|
||||
/// in the query and carry everything else (generationConfig, etc.) in the body.
|
||||
fn gemini_forward_query(
|
||||
query: &std::collections::HashMap<String, String>,
|
||||
drop_alt: bool,
|
||||
) -> Option<String> {
|
||||
let mut parts: Vec<(&String, &String)> = query
|
||||
.iter()
|
||||
.filter(|(k, _)| k.as_str() != "key" && !(drop_alt && k.as_str() == "alt"))
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stable order so the URL is deterministic (and testable).
|
||||
parts.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let encoded = parts
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
Some(encoded)
|
||||
}
|
||||
|
||||
/// GET /v1beta/models — list models in Gemini's native shape.
|
||||
/// Like `/v1/models`, this is a directory listing from configured routes; no
|
||||
/// upstream call, no billing.
|
||||
async fn gemini_list_models(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("GET /v1beta/models");
|
||||
|
||||
let _ = check_gemini_auth(&state, &headers, &query).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT DISTINCT
|
||||
tr.model_pattern,
|
||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||
FROM token_routes tr
|
||||
JOIN route_channels rc ON rc.route_id = tr.id
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE tr.enabled = 1
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND TRIM(COALESCE(s.url, '')) <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let models: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(_pattern, model)| {
|
||||
let name = if model.starts_with("models/") {
|
||||
model.clone()
|
||||
} else {
|
||||
format!("models/{model}")
|
||||
};
|
||||
json!({
|
||||
"name": name,
|
||||
"supportedGenerationMethods": [
|
||||
"generateContent",
|
||||
"streamGenerateContent",
|
||||
"countTokens",
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "models": models })))
|
||||
}
|
||||
|
||||
/// POST /v1beta/models/{model}:{op} — dispatch to the matching Gemini op.
|
||||
async fn gemini_model_operation(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(model_action): axum::extract::Path<String>,
|
||||
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some((model, operation)) = parse_gemini_model_action(&model_action) else {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Unsupported Gemini model action '{model_action}' \
|
||||
(expected {{model}}:generateContent|streamGenerateContent|countTokens)"
|
||||
)));
|
||||
};
|
||||
info!("POST /v1beta/models/{model}:{}", operation.as_str());
|
||||
|
||||
let key_id = check_gemini_auth(&state, &headers, &query).await?;
|
||||
|
||||
match operation {
|
||||
GeminiOperation::GenerateContent => {
|
||||
let extra_query = gemini_forward_query(&query, false);
|
||||
let result =
|
||||
execute_gemini_with_conductor(state, key_id, model, body, extra_query).await?;
|
||||
Ok(Json(result).into_response())
|
||||
}
|
||||
GeminiOperation::StreamGenerateContent => {
|
||||
let extra_query = gemini_forward_query(&query, true);
|
||||
execute_gemini_stream_with_conductor(state, key_id, model, body, extra_query).await
|
||||
}
|
||||
GeminiOperation::CountTokens => {
|
||||
// countTokens is a metadata call (no generation) — route for a
|
||||
// channel but skip billing, mirroring the lighter helper endpoints.
|
||||
let extra_query = gemini_forward_query(&query, false);
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_gemini_operation(
|
||||
state,
|
||||
route,
|
||||
"countTokens",
|
||||
body,
|
||||
extra_query.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(result).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/chat/completions
|
||||
async fn chat_completions(
|
||||
State(state): State<AppState>,
|
||||
@@ -1334,6 +1547,38 @@ fn responses_stream_usage_from_line(line: &str) -> Option<(i64, i64)> {
|
||||
(prompt != 0 || completion != 0).then_some((prompt, completion))
|
||||
}
|
||||
|
||||
/// Extract `(prompt, completion)` tokens from a Gemini response body's
|
||||
/// `usageMetadata` (`promptTokenCount` / `candidatesTokenCount`). Returns
|
||||
/// `(0, 0)` when the field is absent — mirrors `extract_usage` for OpenAI.
|
||||
fn gemini_extract_usage(response: &Value) -> (i64, i64) {
|
||||
let usage = response.get("usageMetadata");
|
||||
let prompt = usage
|
||||
.and_then(|u| u.get("promptTokenCount"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let completion = usage
|
||||
.and_then(|u| u.get("candidatesTokenCount"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
(prompt, completion)
|
||||
}
|
||||
|
||||
/// Per-line usage parser for Gemini `streamGenerateContent?alt=sse` output.
|
||||
/// Gemini emits `data:`-framed JSON chunks; the final chunk carries the
|
||||
/// cumulative `usageMetadata`. Feeds the shared `StreamUsageAccumulator`.
|
||||
fn gemini_stream_usage_from_line(line: &str) -> Option<(i64, i64)> {
|
||||
let payload = line
|
||||
.strip_prefix("data: ")
|
||||
.or_else(|| line.strip_prefix("data:"))?
|
||||
.trim();
|
||||
if payload.is_empty() || payload == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
let value: Value = serde_json::from_str(payload).ok()?;
|
||||
let (prompt, completion) = gemini_extract_usage(&value);
|
||||
(prompt != 0 || completion != 0).then_some((prompt, completion))
|
||||
}
|
||||
|
||||
fn build_conductor(state: &AppState) -> ProxyConductor {
|
||||
let db = state.db.clone();
|
||||
let select_db = db.clone();
|
||||
@@ -1881,6 +2126,185 @@ async fn execute_responses_stream_with_conductor(
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-streaming Gemini op (`generateContent`) through the ProxyConductor.
|
||||
/// Mirrors `execute_responses_with_conductor` but builds a Gemini upstream URL
|
||||
/// (model in path, key in query) and forwards the body verbatim — Gemini bodies
|
||||
/// carry no `model` field, so there is no body rewrite.
|
||||
async fn execute_gemini_with_conductor(
|
||||
state: AppState,
|
||||
key_id: Option<i64>,
|
||||
model: String,
|
||||
body: Value,
|
||||
extra_query: Option<String>,
|
||||
) -> AppResult<Value> {
|
||||
let started_at = Instant::now();
|
||||
let request_log_body = body.clone();
|
||||
let conductor = build_conductor(&state);
|
||||
let billing_state = state.clone();
|
||||
let billing_model = model.clone();
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: model,
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body.clone();
|
||||
let extra_query = extra_query.clone();
|
||||
async move {
|
||||
match executors::execute_gemini_operation(
|
||||
state,
|
||||
selected_channel_to_route(ctx.selected),
|
||||
"generateContent",
|
||||
body,
|
||||
extra_query.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => AttemptResult::Success {
|
||||
response,
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
},
|
||||
Err(err) => AttemptResult::Failure {
|
||||
action: AttemptFailureAction::Failover,
|
||||
status: upstream_status_from_error(&err),
|
||||
raw_error_text: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let (selected, response, attempts) = match result {
|
||||
ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
} => (selected, response, attempts),
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
} => {
|
||||
record_proxy_log_failure(
|
||||
&billing_state,
|
||||
key_id,
|
||||
selected.as_ref(),
|
||||
&billing_model,
|
||||
status,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
raw_error_text.as_deref(),
|
||||
attempts,
|
||||
false,
|
||||
proxy_log_content_from_request(&request_log_body),
|
||||
)
|
||||
.await;
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||
status,
|
||||
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
)));
|
||||
}
|
||||
};
|
||||
let (prompt, completion) = gemini_extract_usage(&response);
|
||||
let cost = estimate_request_cost(&selected, prompt, completion);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
&selected,
|
||||
&billing_model,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
cost,
|
||||
attempts,
|
||||
false,
|
||||
proxy_log_content_from_request_response(&request_log_body, Some(&response)),
|
||||
)
|
||||
.await;
|
||||
record_billing(
|
||||
&billing_state,
|
||||
key_id,
|
||||
&billing_model,
|
||||
prompt,
|
||||
completion,
|
||||
cost,
|
||||
)
|
||||
.await;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Streaming Gemini op (`streamGenerateContent`) through the ProxyConductor.
|
||||
/// Mirrors `execute_responses_stream_with_conductor`, opening the upstream with
|
||||
/// failover and SSE-passing it through with the Gemini usage parser so streamed
|
||||
/// `usageMetadata` is billed.
|
||||
async fn execute_gemini_stream_with_conductor(
|
||||
state: AppState,
|
||||
key_id: Option<i64>,
|
||||
model: String,
|
||||
body: Value,
|
||||
extra_query: Option<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let started_at = Instant::now();
|
||||
let request_log_body = body.clone();
|
||||
let body_for_attempt = body.clone();
|
||||
let opened = open_stream_with_failover(&state, &model, move |state, selected| {
|
||||
let body = body_for_attempt.clone();
|
||||
let extra_query = extra_query.clone();
|
||||
async move {
|
||||
executors::execute_gemini_stream_response(
|
||||
state,
|
||||
selected_channel_to_route(selected),
|
||||
"streamGenerateContent",
|
||||
body,
|
||||
extra_query.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match opened {
|
||||
Ok(opened) => sse_passthrough_response(
|
||||
state,
|
||||
opened,
|
||||
key_id,
|
||||
model,
|
||||
started_at,
|
||||
proxy_log_content_from_request(&request_log_body),
|
||||
gemini_stream_usage_from_line,
|
||||
),
|
||||
Err(failure) => {
|
||||
record_proxy_log_failure(
|
||||
&state,
|
||||
key_id,
|
||||
failure.selected.as_ref(),
|
||||
&model,
|
||||
failure.status,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
failure.raw_error_text.as_deref(),
|
||||
failure.attempts,
|
||||
true,
|
||||
proxy_log_content_from_request(&request_log_body),
|
||||
)
|
||||
.await;
|
||||
Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {} attempts (stream open, status: {:?}): {}",
|
||||
failure.attempts,
|
||||
failure.status,
|
||||
failure
|
||||
.raw_error_text
|
||||
.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2121,8 +2545,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
crate::services::builtin_keys::BuiltinKeys {
|
||||
@@ -2157,4 +2581,87 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(fail_count > 0);
|
||||
}
|
||||
|
||||
// ── Gemini native inbound unit tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_gemini_model_action_known_ops() {
|
||||
let (m, op) = parse_gemini_model_action("gemini-2.5-pro:generateContent").unwrap();
|
||||
assert_eq!(m, "gemini-2.5-pro");
|
||||
assert_eq!(op, GeminiOperation::GenerateContent);
|
||||
|
||||
let (m, op) = parse_gemini_model_action("gemini-2.5-pro:streamGenerateContent").unwrap();
|
||||
assert_eq!(m, "gemini-2.5-pro");
|
||||
assert_eq!(op, GeminiOperation::StreamGenerateContent);
|
||||
|
||||
// model names may carry the `models/` prefix
|
||||
let (m, op) = parse_gemini_model_action("models/gemini-1.5-pro:countTokens").unwrap();
|
||||
assert_eq!(m, "models/gemini-1.5-pro");
|
||||
assert_eq!(op, GeminiOperation::CountTokens);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_gemini_model_action_rejects_bad_input() {
|
||||
// no operation suffix
|
||||
assert!(parse_gemini_model_action("gemini-2.5-pro").is_none());
|
||||
// unknown operation
|
||||
assert!(parse_gemini_model_action("gemini-2.5-pro:embedContent").is_none());
|
||||
// empty model
|
||||
assert!(parse_gemini_model_action(":generateContent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_extract_usage_reads_usage_metadata() {
|
||||
let body = json!({
|
||||
"candidates": [],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 11,
|
||||
"candidatesTokenCount": 22,
|
||||
"totalTokenCount": 33
|
||||
}
|
||||
});
|
||||
assert_eq!(gemini_extract_usage(&body), (11, 22));
|
||||
// absent usageMetadata → zeros
|
||||
assert_eq!(gemini_extract_usage(&json!({})), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_stream_usage_from_line_parses_data_frame() {
|
||||
let line = r#"data: {"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":6,"totalTokenCount":10}}"#;
|
||||
assert_eq!(gemini_stream_usage_from_line(line), Some((4, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_stream_usage_from_line_ignores_non_usage_and_done() {
|
||||
// a chunk with no usageMetadata yields None (don't overwrite accumulator)
|
||||
let no_usage = r#"data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}"#;
|
||||
assert_eq!(gemini_stream_usage_from_line(no_usage), None);
|
||||
// sentinels / blanks / non-data lines
|
||||
assert_eq!(gemini_stream_usage_from_line("data: [DONE]"), None);
|
||||
assert_eq!(gemini_stream_usage_from_line("data:"), None);
|
||||
assert_eq!(gemini_stream_usage_from_line(": comment"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_forward_query_drops_key_and_alt() {
|
||||
let mut q = std::collections::HashMap::new();
|
||||
q.insert("key".to_string(), "secret".to_string());
|
||||
q.insert("alt".to_string(), "sse".to_string());
|
||||
// stream path drops both key and alt → nothing left
|
||||
assert_eq!(gemini_forward_query(&q, true), None);
|
||||
// non-stream keeps alt but always drops key
|
||||
assert_eq!(gemini_forward_query(&q, false), Some("alt=sse".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_forward_query_preserves_other_params_sorted() {
|
||||
let mut q = std::collections::HashMap::new();
|
||||
q.insert("key".to_string(), "secret".to_string());
|
||||
q.insert("pageSize".to_string(), "50".to_string());
|
||||
q.insert("filter".to_string(), "x".to_string());
|
||||
assert_eq!(
|
||||
gemini_forward_query(&q, true),
|
||||
Some("filter=x&pageSize=50".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +696,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
pub(super) fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
||||
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
||||
return inject_external_bootstrap(html);
|
||||
}
|
||||
inject_enhanced_bootstrap(html)
|
||||
}
|
||||
|
||||
fn workflow_secret_cleanup_bootstrap() -> &'static str {
|
||||
r#"<script>
|
||||
(function () {
|
||||
const clearLeakedComfyApiKey = () => {
|
||||
try {
|
||||
localStorage.removeItem('comfy_api_key');
|
||||
sessionStorage.removeItem('comfy_api_key');
|
||||
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
|
||||
} catch (err) {}
|
||||
};
|
||||
clearLeakedComfyApiKey();
|
||||
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn inject_external_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 80);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) throw new Error('没有读取到当前工作流');
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
const summary = report?.summary || {};
|
||||
const lines = [`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`];
|
||||
for (const item of report?.missingNodeTypes || []) lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
for (const item of report?.emptyModelFields || []) lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.missingModelChoices || []) lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.inputFiles || []) if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n'));
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
document.body.appendChild(button);
|
||||
};
|
||||
window.setTimeout(renameComfyUiBrand, 250);
|
||||
window.setTimeout(renameComfyUiBrand, 1200);
|
||||
window.setTimeout(installWorkflowValidationButton, 800);
|
||||
window.setTimeout(installWorkflowValidationButton, 1800);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
pub(super) fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<style id="boosapi-workflow-shell-style">
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') {
|
||||
el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
for (const attr of ['aria-label', 'title']) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value && /Comfy\s*UI|ComfyUI/.test(value)) {
|
||||
el.setAttribute(attr, value.replace(/Comfy\s*UI|ComfyUI/g, 'BoosAI'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const installBoosApiFetchAuth = () => {
|
||||
try {
|
||||
const api = window.app?.api || window.comfyAPI?.api || window.api;
|
||||
if (!api || api.__boosapiFetchAuthPatch || typeof api.fetchApi !== 'function') return;
|
||||
const originalFetchApi = api.fetchApi.bind(api);
|
||||
api.fetchApi = function(route, options) {
|
||||
options ||= {};
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('user_jwt_token');
|
||||
if (token && !headers.has('Authorization')) headers.set('Authorization', 'Bearer ' + token);
|
||||
options.headers = headers;
|
||||
options.credentials ||= 'same-origin';
|
||||
return originalFetchApi(route, options);
|
||||
};
|
||||
api.__boosapiFetchAuthPatch = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const workflowTextMap = new Map(Object.entries({
|
||||
'Add Node': '添加节点',
|
||||
'Search': '搜索',
|
||||
'Reroute': '转接点',
|
||||
'PreviewImage': '预览图像',
|
||||
'SaveImage': '保存图像',
|
||||
'Load Image': '加载图像',
|
||||
'CheckpointLoaderSimple': 'Checkpoint 加载器(简易)',
|
||||
'CLIPTextEncode': 'CLIP 文本编码',
|
||||
'KSampler': 'K 采样器',
|
||||
'VAEDecode': 'VAE 解码',
|
||||
'VAEEncode': 'VAE 编码',
|
||||
'EmptyLatentImage': '空潜空间图像',
|
||||
'Latent': '潜空间',
|
||||
'Input': '输入',
|
||||
'Output': '输出',
|
||||
'Util': '工具',
|
||||
'utils': '工具',
|
||||
'ComfyUI': 'BoosAI',
|
||||
'IMAGE': '图像',
|
||||
'LATENT': '潜空间',
|
||||
'MODEL': '模型',
|
||||
'CLIP': 'CLIP',
|
||||
'VAE': 'VAE',
|
||||
'CONDITIONING': '条件',
|
||||
'BoosAPI': 'BoosAPI',
|
||||
'boosapi.image_to_video': 'BoosAPI 图生视频',
|
||||
'boosapi.text_to_video': 'BoosAPI 文生视频',
|
||||
'boosapi.video_status': '查询视频任务',
|
||||
'output.preview_video': '输出:预览视频',
|
||||
'output.preview_image': '输出:预览图像',
|
||||
'input.text': '输入:文本',
|
||||
'boosapi.chat_completion': 'BoosAPI 对话',
|
||||
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
|
||||
'boosapi.text_to_image': 'BoosAPI 文生图',
|
||||
'output.save_file': '输出:保存文件'
|
||||
}));
|
||||
const translateWorkflowText = (text) => {
|
||||
const raw = String(text || '');
|
||||
const trimmed = raw.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return null;
|
||||
if (workflowTextMap.has(trimmed)) return workflowTextMap.get(trimmed);
|
||||
return null;
|
||||
};
|
||||
const localizeWorkflowMenus = () => {
|
||||
const selector = [
|
||||
'.litecontextmenu .litemenu-entry',
|
||||
'.litecontextmenu .litemenu-title',
|
||||
'.litecontextmenu .litemenu-submenu',
|
||||
'.litecontextmenu li',
|
||||
'.litecontextmenu div',
|
||||
'.p-contextmenu [role="menuitem"]',
|
||||
'.p-contextmenu .p-menuitem-text',
|
||||
'.p-tieredmenu [role="menuitem"]',
|
||||
'.p-tieredmenu .p-menuitem-text',
|
||||
'[role="menu"] [role="menuitem"]'
|
||||
].join(',');
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
if (!el || el.children.length > 1) continue;
|
||||
const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && String(node.textContent || '').trim());
|
||||
const target = textNode || (el.children.length === 0 ? el : null);
|
||||
if (!target) continue;
|
||||
const translated = translateWorkflowText(target.textContent);
|
||||
if (translated && target.textContent !== translated) target.textContent = translated;
|
||||
}
|
||||
for (const el of document.querySelectorAll('[title],[aria-label]')) {
|
||||
for (const attr of ['title', 'aria-label']) {
|
||||
const translated = translateWorkflowText(el.getAttribute(attr));
|
||||
if (translated) el.setAttribute(attr, translated);
|
||||
}
|
||||
}
|
||||
};
|
||||
const observeWorkflowMenuLocalization = () => {
|
||||
if (window.__boosapiWorkflowMenuI18nObserver) return;
|
||||
window.__boosapiWorkflowMenuI18nObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(localizeWorkflowMenus);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const normalizeWorkflowPortType = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning';
|
||||
if (normalized === 'latent' || normalized === '潜空间') return 'latent';
|
||||
if (normalized === 'model' || normalized === '模型') return 'model';
|
||||
if (normalized === 'clip') return 'clip';
|
||||
if (normalized === 'vae') return 'vae';
|
||||
if (normalized === 'image' || normalized === '图像' || normalized === '图片') return 'image';
|
||||
if (normalized === 'video' || normalized === '视频') return 'video';
|
||||
return normalized;
|
||||
};
|
||||
const normalizeWorkflowSlotType = (slot) => {
|
||||
if (!slot) return;
|
||||
const normalized = normalizeWorkflowPortType(slot.type || slot.name || slot.localized_name);
|
||||
const canonical = {
|
||||
conditioning: 'CONDITIONING',
|
||||
latent: 'LATENT',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
image: 'IMAGE',
|
||||
video: 'VIDEO'
|
||||
}[normalized];
|
||||
if (canonical) slot.type = canonical;
|
||||
};
|
||||
const slotName = (slot) => String(slot?.name || slot?.localized_name || slot?.label || '').trim().toLowerCase();
|
||||
const normalizeWorkflowNodeSlots = (node) => {
|
||||
try {
|
||||
if (!node) return;
|
||||
if (node.type === 'CLIPTextEncode' && Array.isArray(node.outputs)) {
|
||||
for (const output of node.outputs) {
|
||||
const name = slotName(output);
|
||||
if (name.includes('conditioning') || name.includes('condition') || name.includes('条件') || name === 'conditioning') {
|
||||
output.type = 'CONDITIONING';
|
||||
output.name = output.name || 'CONDITIONING';
|
||||
output.localized_name = output.localized_name || '条件';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'KSampler' && Array.isArray(node.inputs)) {
|
||||
for (const input of node.inputs) {
|
||||
const name = slotName(input);
|
||||
if (name === 'positive' || name === 'negative' || name.includes('positive') || name.includes('negative') || name.includes('正面条件') || name.includes('负面条件')) {
|
||||
input.type = 'CONDITIONING';
|
||||
}
|
||||
}
|
||||
}
|
||||
node.inputs?.forEach(normalizeWorkflowSlotType);
|
||||
node.outputs?.forEach(normalizeWorkflowSlotType);
|
||||
} catch (err) {}
|
||||
};
|
||||
const normalizeWorkflowGraphSlots = () => {
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (!graph?.nodes) return;
|
||||
for (const node of graph.nodes) normalizeWorkflowNodeSlots(node);
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
window.app?.canvas?.setDirty?.(true, true);
|
||||
} catch (err) {}
|
||||
};
|
||||
const patchWorkflowConnectionTypes = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph || liteGraph.__boosapiConnectionPatch) return;
|
||||
const original = liteGraph.isValidConnection;
|
||||
if (typeof original !== 'function') return;
|
||||
liteGraph.isValidConnection = function (typeA, typeB) {
|
||||
const a = normalizeWorkflowPortType(typeA);
|
||||
const b = normalizeWorkflowPortType(typeB);
|
||||
if (a && b && a === b) return true;
|
||||
return original.call(this, typeA, typeB);
|
||||
};
|
||||
const proto = window.LGraphNode?.prototype;
|
||||
if (proto?.connect && !proto.__boosapiConnectPatch) {
|
||||
const originalConnect = proto.connect;
|
||||
proto.connect = function (slot, targetNode, targetSlot, afterRerouteId) {
|
||||
normalizeWorkflowNodeSlots(this);
|
||||
normalizeWorkflowNodeSlots(targetNode);
|
||||
return originalConnect.call(this, slot, targetNode, targetSlot, afterRerouteId);
|
||||
};
|
||||
proto.__boosapiConnectPatch = true;
|
||||
}
|
||||
const originalCreateNode = liteGraph.createNode;
|
||||
if (typeof originalCreateNode === 'function' && !liteGraph.__boosapiCreateNodePatch) {
|
||||
liteGraph.createNode = function () {
|
||||
const node = originalCreateNode.apply(this, arguments);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
return node;
|
||||
};
|
||||
liteGraph.__boosapiCreateNodePatch = true;
|
||||
}
|
||||
liteGraph.__boosapiConnectionPatch = true;
|
||||
normalizeWorkflowGraphSlots();
|
||||
} catch (err) {}
|
||||
};
|
||||
const startWorkflowSlotNormalizer = () => {
|
||||
if (window.__boosapiWorkflowSlotNormalizer) return;
|
||||
window.__boosapiWorkflowSlotNormalizer = true;
|
||||
window.setInterval(normalizeWorkflowGraphSlots, 1000);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 100);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 600);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
|
||||
};
|
||||
const workflowConnectionQuickNodes = {
|
||||
model: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '把模型接入采样流程' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader', 'LoraLoaderModelOnly'], hint: '给模型叠加 LoRA' },
|
||||
{ title: '模型采样设置', types: ['ModelSamplingDiscrete', 'ModelSamplingContinuousEDM'], hint: '调整模型采样方式' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
clip: [
|
||||
{ title: 'CLIP 文本编码', types: ['CLIPTextEncode'], hint: '输入提示词生成条件' },
|
||||
{ title: 'CLIP 跳层', types: ['CLIPSetLastLayer'], hint: '设置 CLIP skip' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader'], hint: '同时接入模型与 CLIP' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
vae: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
image: [
|
||||
{ title: '预览图像', types: ['PreviewImage'], hint: '查看这一条图像输出' },
|
||||
{ title: '保存图像', types: ['SaveImage'], hint: '保存到输出目录' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '放大图像', types: ['ImageScale', 'ImageScaleBy', 'ImageUpscaleWithModel'], hint: '调整尺寸/放大' },
|
||||
{ title: '图像对比', types: ['Image Comparer (rgthree)', 'Image Comparer', 'ImageCompare'], hint: '对比输入图像' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' }
|
||||
],
|
||||
latent: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '继续采样' },
|
||||
{ title: '潜空间放大', types: ['LatentUpscale', 'LatentUpscaleBy'], hint: '调整潜空间尺寸' },
|
||||
{ title: '潜空间合成', types: ['LatentComposite'], hint: '合成两个潜空间' }
|
||||
],
|
||||
conditioning: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '接入正/负条件' },
|
||||
{ title: '合并条件', types: ['ConditioningCombine', 'ConditioningConcat'], hint: '组合提示条件' },
|
||||
{ title: '条件平均', types: ['ConditioningAverage'], hint: '混合两组条件' },
|
||||
{ title: '条件区域', types: ['ConditioningSetArea'], hint: '限制条件作用区域' }
|
||||
],
|
||||
mask: [
|
||||
{ title: '遮罩转图像', types: ['MaskToImage'], hint: '查看/保存遮罩' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' },
|
||||
{ title: '遮罩合成', types: ['MaskComposite'], hint: '组合遮罩' },
|
||||
{ title: '遮罩扩张', types: ['GrowMask'], hint: '扩大或收缩遮罩' }
|
||||
],
|
||||
video: [
|
||||
{ title: '预览视频', types: ['VHS_VideoCombine', 'Video Combine 🎥🅥🅗🅢', 'VideoCombine'], hint: '合成为可预览视频' },
|
||||
{ title: '保存动画', types: ['SaveAnimatedPNG', 'SaveAnimatedWEBP'], hint: '保存序列动画' },
|
||||
{ title: '加载视频', types: ['VHS_LoadVideo', 'LoadVideo'], hint: '读取视频素材' }
|
||||
],
|
||||
audio: [
|
||||
{ title: '预览音频', types: ['PreviewAudio', 'SaveAudio'], hint: '试听或保存音频' },
|
||||
{ title: '加载音频', types: ['LoadAudio'], hint: '读取音频素材' }
|
||||
],
|
||||
string: [
|
||||
{ title: '文本输入', types: ['PrimitiveNode', 'StringConstant', 'Text Input'], hint: '创建文本值' },
|
||||
{ title: '显示文本', types: ['ShowText|pysssss', 'ShowText', 'PreviewText'], hint: '查看文本输出' }
|
||||
],
|
||||
int: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Integer Constant'], hint: '创建整数值' }
|
||||
],
|
||||
float: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Float Constant'], hint: '创建小数值' }
|
||||
],
|
||||
boolean: [
|
||||
{ title: '开关输入', types: ['PrimitiveNode', 'Boolean Constant'], hint: '创建布尔值' }
|
||||
]
|
||||
};
|
||||
const findWorkflowNodeType = (candidates) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
for (const name of candidates) {
|
||||
if (registry[name]) return name;
|
||||
}
|
||||
const entries = Object.keys(registry);
|
||||
for (const name of candidates) {
|
||||
const normalized = String(name).toLowerCase().replace(/[\s_-]+/g, '');
|
||||
const found = entries.find((item) => String(item).toLowerCase().replace(/[\s_-]+/g, '') === normalized);
|
||||
if (found) return found;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const getWorkflowConnectionType = (options) => {
|
||||
try {
|
||||
const link = options || window.__boosapiLastConnectionMenuOptions || {};
|
||||
if (link.nodeFrom && Number.isInteger(link.slotFrom)) {
|
||||
return normalizeWorkflowPortType(link.nodeFrom.outputs?.[link.slotFrom]?.type || link.nodeFrom.outputs?.[link.slotFrom]?.name);
|
||||
}
|
||||
if (link.nodeTo && Number.isInteger(link.slotTo)) {
|
||||
return normalizeWorkflowPortType(link.nodeTo.inputs?.[link.slotTo]?.type || link.nodeTo.inputs?.[link.slotTo]?.name);
|
||||
}
|
||||
const title = String(document.querySelector('.litecontextmenu .litemenu-title')?.textContent || '');
|
||||
if (title.includes('IMAGE') || title.includes('图像')) return 'image';
|
||||
if (title.includes('LATENT') || title.includes('潜空间')) return 'latent';
|
||||
if (title.includes('CONDITIONING') || title.includes('条件')) return 'conditioning';
|
||||
if (title.includes('MODEL') || title.includes('模型')) return 'model';
|
||||
if (title.includes('CLIP')) return 'clip';
|
||||
if (title.includes('VAE')) return 'vae';
|
||||
if (title.includes('MASK') || title.includes('遮罩')) return 'mask';
|
||||
if (title.includes('VIDEO') || title.includes('视频')) return 'video';
|
||||
if (title.includes('AUDIO') || title.includes('音频')) return 'audio';
|
||||
if (title.includes('STRING') || title.includes('TEXT') || title.includes('文本')) return 'string';
|
||||
if (title.includes('INT') || title.includes('整数')) return 'int';
|
||||
if (title.includes('FLOAT') || title.includes('NUMBER') || title.includes('数字')) return 'float';
|
||||
if (title.includes('BOOLEAN') || title.includes('BOOL') || title.includes('布尔')) return 'boolean';
|
||||
} catch (err) {}
|
||||
return '';
|
||||
};
|
||||
const workflowNodeDisplayTitle = (nodeType) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
const ctor = registry[nodeType];
|
||||
return String(ctor?.title || ctor?.nodeData?.display_name || ctor?.comfyClass || nodeType || '').trim();
|
||||
};
|
||||
const fallbackWorkflowConnectionItems = (connectionType) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const liteGraph = window.LiteGraph || {};
|
||||
const canonical = {
|
||||
image: 'IMAGE',
|
||||
latent: 'LATENT',
|
||||
conditioning: 'CONDITIONING',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
mask: 'MASK',
|
||||
video: 'VIDEO',
|
||||
audio: 'AUDIO',
|
||||
string: 'STRING',
|
||||
int: 'INT',
|
||||
float: 'FLOAT',
|
||||
boolean: 'BOOLEAN'
|
||||
}[connectionType] || String(connectionType || '').toUpperCase();
|
||||
const defaults = options.nodeFrom
|
||||
? liteGraph.slot_types_default_out?.[canonical]
|
||||
: liteGraph.slot_types_default_in?.[canonical];
|
||||
const registered = options.nodeFrom
|
||||
? liteGraph.registered_slot_in_types?.[String(canonical).toLowerCase()]?.nodes
|
||||
: liteGraph.registered_slot_out_types?.[canonical]?.nodes;
|
||||
const names = [...(Array.isArray(defaults) ? defaults : []), ...(Array.isArray(registered) ? registered : [])]
|
||||
.map((value) => typeof value === 'string' ? value : value?.comfyClass || value?.type || value?.title)
|
||||
.filter(Boolean);
|
||||
return [...new Set(names)].slice(0, 8).map((nodeType) => ({
|
||||
title: workflowNodeDisplayTitle(nodeType),
|
||||
types: [nodeType],
|
||||
hint: `${canonical} 兼容节点`
|
||||
}));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const findCompatibleSlotIndex = (slots, type) => {
|
||||
const wanted = normalizeWorkflowPortType(type);
|
||||
if (!Array.isArray(slots)) return 0;
|
||||
const exact = slots.findIndex((slot) => normalizeWorkflowPortType(slot?.type || slot?.name || slot?.localized_name) === wanted);
|
||||
return exact >= 0 ? exact : 0;
|
||||
};
|
||||
const connectedNodePosition = (options) => {
|
||||
const event = options?.e || {};
|
||||
if (typeof event.canvasX === 'number' && typeof event.canvasY === 'number') {
|
||||
return [event.canvasX + 24, event.canvasY - 28];
|
||||
}
|
||||
try {
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
if (Array.isArray(area)) return [area[0] + area[2] / 2 - 120, area[1] + area[3] / 2 - 80];
|
||||
} catch (err) {}
|
||||
return [180, 180];
|
||||
};
|
||||
const addConnectedWorkflowQuickNode = (item) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const nodeType = findWorkflowNodeType(item.types || []);
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!nodeType || !graph || !window.LiteGraph?.createNode) return false;
|
||||
const node = window.LiteGraph.createNode(nodeType, item.title || nodeType);
|
||||
if (!node) return false;
|
||||
node.pos = connectedNodePosition(options);
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
if (options.nodeFrom && Number.isInteger(options.slotFrom)) {
|
||||
const output = options.nodeFrom.outputs?.[options.slotFrom];
|
||||
const inputIndex = findCompatibleSlotIndex(node.inputs, output?.type || output?.name);
|
||||
options.nodeFrom.connect(options.slotFrom, node, inputIndex, options.afterRerouteId);
|
||||
} else if (options.nodeTo && Number.isInteger(options.slotTo)) {
|
||||
const input = options.nodeTo.inputs?.[options.slotTo];
|
||||
const outputIndex = findCompatibleSlotIndex(node.outputs, input?.type || input?.name);
|
||||
node.connect(outputIndex, options.nodeTo, options.slotTo, options.afterRerouteId);
|
||||
}
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
if (window.LiteGraph?.closeAllContextMenus) window.LiteGraph.closeAllContextMenus(window);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const installWorkflowConnectionMenuMemory = () => {
|
||||
try {
|
||||
const proto = window.LGraphCanvas?.prototype;
|
||||
if (!proto?.showConnectionMenu || proto.__boosapiConnectionMenuMemory) return;
|
||||
const original = proto.showConnectionMenu;
|
||||
proto.showConnectionMenu = function (options) {
|
||||
window.__boosapiLastConnectionMenuOptions = options || {};
|
||||
const result = original.apply(this, arguments);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 0);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 80);
|
||||
return result;
|
||||
};
|
||||
proto.__boosapiConnectionMenuMemory = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const enhanceWorkflowConnectionMenu = () => {
|
||||
try {
|
||||
const menu = document.querySelector('.litecontextmenu');
|
||||
if (!menu || menu.__boosapiConnectionSuggestions) return;
|
||||
const connectionType = getWorkflowConnectionType();
|
||||
const items = [
|
||||
...(workflowConnectionQuickNodes[connectionType] || []),
|
||||
...fallbackWorkflowConnectionItems(connectionType)
|
||||
];
|
||||
if (!items?.length) return;
|
||||
const available = items
|
||||
.map((item) => ({ ...item, nodeType: findWorkflowNodeType(item.types || []) }))
|
||||
.filter((item) => item.nodeType);
|
||||
if (!available.length) return;
|
||||
menu.__boosapiConnectionSuggestions = true;
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'boosapi-connection-suggestions';
|
||||
panel.style.cssText = 'padding:8px 8px 6px;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(20,24,30,.96);min-width:260px;';
|
||||
const title = document.createElement('div');
|
||||
title.textContent = '最相关';
|
||||
title.style.cssText = 'font-size:12px;font-weight:700;color:#e9f7f5;margin:0 0 6px 2px;';
|
||||
panel.appendChild(title);
|
||||
for (const item of available.slice(0, 6)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = item.title;
|
||||
button.title = item.hint || item.nodeType;
|
||||
button.style.cssText = 'display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:2px 0;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:#fff;text-align:left;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(20,184,166,.22)'; });
|
||||
button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; });
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
addConnectedWorkflowQuickNode(item);
|
||||
}, true);
|
||||
panel.appendChild(button);
|
||||
}
|
||||
menu.insertBefore(panel, menu.firstChild);
|
||||
} catch (err) {}
|
||||
};
|
||||
const installWorkflowConnectionMenuObserver = () => {
|
||||
if (window.__boosapiWorkflowConnectionMenuObserver) return;
|
||||
window.__boosapiWorkflowConnectionMenuObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(enhanceWorkflowConnectionMenu);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const boostWorkflowSlotDefaults = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph) return;
|
||||
const prepend = (target, type, values) => {
|
||||
const current = Array.isArray(target[type]) ? target[type] : [];
|
||||
target[type] = [...values.filter((value) => findWorkflowNodeType([value])), ...current.filter((value) => !values.includes(value))].slice(0, 24);
|
||||
};
|
||||
liteGraph.slot_types_default_out ||= {};
|
||||
liteGraph.slot_types_default_in ||= {};
|
||||
prepend(liteGraph.slot_types_default_out, 'MODEL', ['KSampler', 'LoraLoader', 'ModelSamplingDiscrete']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CLIP', ['CLIPTextEncode', 'CLIPSetLastLayer', 'LoraLoader']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VAE', ['VAEDecode', 'VAEEncode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'IMAGE', ['PreviewImage', 'SaveImage', 'VAEEncode', 'ImageScale', 'ImageScaleBy']);
|
||||
prepend(liteGraph.slot_types_default_in, 'IMAGE', ['LoadImage', 'EmptyImage', 'PreviewImage']);
|
||||
prepend(liteGraph.slot_types_default_out, 'LATENT', ['VAEDecode', 'KSampler']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CONDITIONING', ['KSampler', 'ConditioningCombine']);
|
||||
prepend(liteGraph.slot_types_default_out, 'MASK', ['MaskToImage', 'ImageToMask', 'MaskComposite', 'GrowMask']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VIDEO', ['VHS_VideoCombine', 'VideoCombine', 'SaveAnimatedPNG']);
|
||||
prepend(liteGraph.slot_types_default_out, 'AUDIO', ['PreviewAudio', 'SaveAudio', 'LoadAudio']);
|
||||
prepend(liteGraph.slot_types_default_out, 'STRING', ['PrimitiveNode', 'ShowText', 'PreviewText']);
|
||||
prepend(liteGraph.slot_types_default_out, 'INT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'FLOAT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'BOOLEAN', ['PrimitiveNode']);
|
||||
} catch (err) {}
|
||||
};
|
||||
const isWorkflowVideoModelName = (modelName) => {
|
||||
const value = String(modelName || '').toLowerCase();
|
||||
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
|
||||
};
|
||||
const addTextToImageNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_image', 'BoosAPI 文生图');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_image';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addTextToVideoNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_video';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const patchModelLibraryClicks = () => {
|
||||
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
|
||||
if (el.__boosapiModelClickPatch) return;
|
||||
el.__boosapiModelClickPatch = true;
|
||||
el.addEventListener('dblclick', (event) => {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.includes('checkpoints')) return;
|
||||
const added = isWorkflowVideoModelName(text)
|
||||
? addTextToVideoNodeFromModel(text)
|
||||
: addTextToImageNodeFromModel(text);
|
||||
if (added) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
};
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const showWorkflowValidationReport = (report) => {
|
||||
const lines = [];
|
||||
const summary = report?.summary || {};
|
||||
lines.push(`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`);
|
||||
for (const item of report?.missingNodeTypes || []) {
|
||||
lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
}
|
||||
for (const item of report?.emptyModelFields || []) {
|
||||
lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
}
|
||||
for (const item of report?.missingModelChoices || []) {
|
||||
lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
if (item.near?.length) lines.push(` 相近: ${item.near.join(', ')}`);
|
||||
}
|
||||
for (const item of report?.inputFiles || []) {
|
||||
if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
}
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n') || '没有发现明显问题');
|
||||
};
|
||||
const runWorkflowValidation = async () => {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) {
|
||||
window.alert('没有读取到当前工作流');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
showWorkflowValidationReport(report);
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const host = document.querySelector('header, nav, .p-menubar, .comfy-menu, .comfyui-menu') || document.body;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
await runWorkflowValidation();
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
host.appendChild(button);
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
let shellPatchQueued = false;
|
||||
const patchWorkflowShell = () => {
|
||||
shellPatchQueued = false;
|
||||
installBoosApiFetchAuth();
|
||||
patchWorkflowConnectionTypes();
|
||||
startWorkflowSlotNormalizer();
|
||||
installWorkflowConnectionMenuMemory();
|
||||
installWorkflowConnectionMenuObserver();
|
||||
boostWorkflowSlotDefaults();
|
||||
enhanceWorkflowConnectionMenu();
|
||||
patchModelLibraryClicks();
|
||||
installWorkflowValidationButton();
|
||||
localizeWorkflowMenus();
|
||||
observeWorkflowMenuLocalization();
|
||||
renameComfyUiBrand();
|
||||
};
|
||||
const queueWorkflowShellPatch = () => {
|
||||
if (shellPatchQueued) return;
|
||||
shellPatchQueued = true;
|
||||
window.setTimeout(patchWorkflowShell, 120);
|
||||
};
|
||||
document.addEventListener('click', queueWorkflowShellPatch, true);
|
||||
window.setTimeout(patchWorkflowShell, 250);
|
||||
window.setTimeout(patchWorkflowShell, 1200);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
use super::auth::{auth_caller, caller_is_admin, caller_user_id};
|
||||
use super::models::workflow_route_models;
|
||||
use super::node_defs::{inject_boosapi_workflow_template_payload, workflow_template_payload};
|
||||
use super::{
|
||||
auth_caller_optional, inject_boosapi_object_info, native_workflow_json_cached,
|
||||
native_workflow_metadata_ttl, native_workflow_status_ttl, parse_boolish, safe_view_path,
|
||||
user_home, workflow_node_defs_with_models, UserDataQuery, WorkflowCaller,
|
||||
};
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(super) async fn get_user_config(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let audience = match caller {
|
||||
WorkflowCaller::Admin => "admin",
|
||||
WorkflowCaller::User { .. } => "user",
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"username": audience,
|
||||
"role": audience,
|
||||
"id": caller_user_id(&caller),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
Ok((
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
Json(json!({ "error": "read-only" })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn get_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
return Ok(Json(json!({
|
||||
"Comfy.Workflow.ShowMissingModelsWarning": false
|
||||
})));
|
||||
}
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
pub(super) async fn get_setting(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning"
|
||||
{
|
||||
return Ok(Json(Value::Bool(false)));
|
||||
}
|
||||
Ok(Json(Value::Null))
|
||||
}
|
||||
|
||||
pub(super) async fn store_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(_body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
pub(super) async fn store_setting(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<String>,
|
||||
Json(_body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
pub(super) async fn userdata_list(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<UserDataQuery>,
|
||||
) -> AppResult<Response> {
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let user_id = caller_user_id(&caller);
|
||||
let dir = query.dir.unwrap_or_else(|| "workflow".to_string());
|
||||
let base = user_home(user_id).join("userdata").join(&dir);
|
||||
if tokio::fs::metadata(&base).await.is_err() {
|
||||
return Ok(Json(json!([])).into_response());
|
||||
}
|
||||
if query.full_info {
|
||||
let mut entries = Vec::new();
|
||||
let mut reader = match tokio::fs::read_dir(&base).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(Json(json!([])).into_response()),
|
||||
};
|
||||
while let Ok(Some(entry)) = reader.next_entry().await {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => continue,
|
||||
};
|
||||
entries.push(json!({
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"name": entry.file_name().to_string_lossy().to_string(),
|
||||
"type": if meta.is_dir() { "directory" } else { "file" },
|
||||
"size": meta.len(),
|
||||
}));
|
||||
}
|
||||
return Ok(Json(Value::Array(entries)).into_response());
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut reader = match tokio::fs::read_dir(&base).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(Json(json!([])).into_response()),
|
||||
};
|
||||
while let Ok(Some(entry)) = reader.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if query.split {
|
||||
items.push(json!([name.clone()]));
|
||||
} else {
|
||||
items.push(Value::String(name));
|
||||
}
|
||||
}
|
||||
Ok(Json(Value::Array(items)).into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn userdata_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(file): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let root = user_home(caller_user_id(&caller)).join("userdata");
|
||||
let path = root.join(safe_view_path(&file));
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound("User data file not found".into()))?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn userdata_store(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(file): Path<String>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let root = user_home(caller_user_id(&caller)).join("userdata");
|
||||
let path = root.join(safe_view_path(&file));
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
}
|
||||
tokio::fs::write(&path, body)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"name": file,
|
||||
"subfolder": query.get("subfolder").cloned().unwrap_or_default(),
|
||||
"type": query.get("type").cloned().unwrap_or_else(|| "input".to_string())
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn userdata_delete(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(file): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let root = user_home(caller_user_id(&caller)).join("userdata");
|
||||
let path = root.join(safe_view_path(&file));
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn move_userdata(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((source, dest)): Path<(String, String)>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let root = user_home(caller_user_id(&caller)).join("userdata");
|
||||
let src = root.join(safe_view_path(&source));
|
||||
let dst = root.join(safe_view_path(&dest));
|
||||
if let Some(parent) = dst.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
}
|
||||
let overwrite = parse_boolish(query.get("overwrite").map(String::as_str));
|
||||
if !overwrite && tokio::fs::metadata(&dst).await.is_ok() {
|
||||
return Err(AppError::BadRequest("destination already exists".into()));
|
||||
}
|
||||
tokio::fs::rename(&src, &dst)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({ "success": true })).into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn extensions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
Ok(Json(json!(["extensions/core"])))
|
||||
}
|
||||
|
||||
pub(super) async fn workflow_templates(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
let mut value = native_workflow_json_cached(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/workflow_templates",
|
||||
None,
|
||||
native_workflow_metadata_ttl(&state),
|
||||
)
|
||||
.await
|
||||
.map(|value| value.0)
|
||||
.unwrap_or_else(|_| json!({}));
|
||||
inject_boosapi_workflow_template_payload(&mut value);
|
||||
return Ok(Json(value));
|
||||
}
|
||||
Ok(Json(workflow_template_payload()))
|
||||
}
|
||||
|
||||
pub(super) async fn embeddings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
Ok(Json(json!([])))
|
||||
}
|
||||
|
||||
pub(super) async fn object_info(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let mut value = native_workflow_json_cached(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/object_info",
|
||||
None,
|
||||
native_workflow_metadata_ttl(&state),
|
||||
)
|
||||
.await?
|
||||
.0;
|
||||
inject_boosapi_object_info(&mut value, &route_models);
|
||||
return Ok(Json(value));
|
||||
}
|
||||
Ok(Json(Value::Object(workflow_node_defs_with_models(
|
||||
&route_models,
|
||||
true,
|
||||
))))
|
||||
}
|
||||
|
||||
pub(super) async fn system_stats(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
return native_workflow_json_cached(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/system_stats",
|
||||
None,
|
||||
native_workflow_status_ttl(&state),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
state.config.server.host.as_str()
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"system": {
|
||||
"host": host,
|
||||
"port": state.config.server.port,
|
||||
"role": if caller_is_admin(&caller) { "admin" } else { "user" }
|
||||
},
|
||||
"devices": [],
|
||||
"queue_remaining": 0,
|
||||
})))
|
||||
}
|
||||
+94
-4108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
use super::models::{image_model_choices, is_video_workflow_model};
|
||||
use super::node_defs::{core_node_definitions, node_type_def_to_comfy};
|
||||
use super::DEFAULT_TEXT_TO_VIDEO_MODEL;
|
||||
use crate::workflow::registry;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(super) fn comfy_combo_input(options: &[String], fallback: &str) -> Value {
|
||||
let choices = if options.is_empty() {
|
||||
vec![fallback.to_string()]
|
||||
} else {
|
||||
options.to_vec()
|
||||
};
|
||||
let default = choices
|
||||
.iter()
|
||||
.find(|model| model.as_str() == fallback)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| choices[0].clone());
|
||||
json!([choices, { "default": default }])
|
||||
}
|
||||
|
||||
pub(super) fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
|
||||
let image_models = image_model_choices(models);
|
||||
let video_models = models
|
||||
.iter()
|
||||
.filter(|model| is_video_workflow_model(model))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(node) = defs.get_mut("boosapi.text_to_image") {
|
||||
if let Some(optional) = node
|
||||
.pointer_mut("/input/optional")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
optional.insert(
|
||||
"model".to_string(),
|
||||
comfy_combo_input(&image_models, "gpt-image-2"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for node_type in [
|
||||
"boosapi.text_to_video",
|
||||
"boosapi.image_to_video",
|
||||
"boosapi.video_status",
|
||||
] {
|
||||
if let Some(node) = defs.get_mut(node_type) {
|
||||
if let Some(optional) = node
|
||||
.pointer_mut("/input/optional")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
optional.insert(
|
||||
"model".to_string(),
|
||||
comfy_combo_input(&video_models, DEFAULT_TEXT_TO_VIDEO_MODEL),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for node_type in ["boosapi.chat_completion", "boosapi.comfyui_workflow"] {
|
||||
if let Some(node) = defs.get_mut(node_type) {
|
||||
if let Some(optional) = node
|
||||
.pointer_mut("/input/optional")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
optional.insert("model".to_string(), comfy_combo_input(models, "gpt-5.4"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_boosapi_extension_node_type(node_type: &str) -> bool {
|
||||
node_type.starts_with("boosapi.")
|
||||
|| matches!(
|
||||
node_type,
|
||||
"input.text"
|
||||
| "output.preview_image"
|
||||
| "output.preview_video"
|
||||
| "output.save_file"
|
||||
| "util.text_join"
|
||||
| "util.json_extract"
|
||||
| "comfyui.submit_workflow"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_node_defs_with_models(
|
||||
models: &[String],
|
||||
include_compat_core: bool,
|
||||
) -> Map<String, Value> {
|
||||
let core_defs = include_compat_core
|
||||
.then(core_node_definitions)
|
||||
.unwrap_or_default();
|
||||
let mut defs = core_defs
|
||||
.into_iter()
|
||||
.chain(
|
||||
registry::node_types()
|
||||
.into_iter()
|
||||
.filter(|def| include_compat_core || is_boosapi_extension_node_type(def.node_type)),
|
||||
)
|
||||
.map(|def| (def.node_type.to_string(), node_type_def_to_comfy(&def)))
|
||||
.collect::<Map<_, _>>();
|
||||
inject_model_choices(&mut defs, models);
|
||||
defs
|
||||
}
|
||||
|
||||
pub(super) fn inject_boosapi_object_info(value: &mut Value, models: &[String]) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for (key, def) in workflow_node_defs_with_models(models, false) {
|
||||
object.entry(key).or_insert(def);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn builtin_model_folders() -> Vec<&'static str> {
|
||||
vec![
|
||||
"boosapi_models",
|
||||
"checkpoints",
|
||||
"chat_models",
|
||||
"video_models",
|
||||
"audio_tts_models",
|
||||
"embeddings",
|
||||
"vision_models",
|
||||
"3d_models",
|
||||
"loras",
|
||||
"vae",
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn inject_boosapi_model_folders(value: &mut Value) {
|
||||
let Some(items) = value.as_array_mut() else {
|
||||
*value = workflow_model_folder_payload();
|
||||
return;
|
||||
};
|
||||
let mut seen = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
for name in builtin_model_folders() {
|
||||
if seen.insert(name.to_ascii_lowercase()) {
|
||||
items.push(json!({ "name": name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workflow_model_folder_payload() -> Value {
|
||||
Value::Array(
|
||||
builtin_model_folders()
|
||||
.into_iter()
|
||||
.map(|name| json!({ "name": name }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn native_model_folder_payload_or_fallback(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Array(_) => {
|
||||
let mut value = value;
|
||||
normalize_model_folder_payload(&mut value);
|
||||
inject_boosapi_model_folders(&mut value);
|
||||
value
|
||||
}
|
||||
Value::Object(object)
|
||||
if object
|
||||
.get("code")
|
||||
.and_then(Value::as_i64)
|
||||
.is_some_and(|code| code == 404) =>
|
||||
{
|
||||
workflow_model_folder_payload()
|
||||
}
|
||||
_ => workflow_model_folder_payload(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn model_payload_items_from_names(names: Vec<String>, path_index: i64) -> Vec<Value> {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| json!({ "name": name, "pathIndex": path_index }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn normalize_model_item_name(item: &Value) -> Option<String> {
|
||||
item.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_model_display_aliases(name: &str) -> Vec<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut aliases = Vec::new();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
|
||||
if lower == "ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors" {
|
||||
aliases.push(
|
||||
"ltx_2.3_22b_distilled_1.1_lora_dynamic_fro09_avg_rank_111_bf16.safetensors"
|
||||
.to_string(),
|
||||
);
|
||||
aliases.push(
|
||||
"ltx_2.3_22b_distilled_1.1_lora_dynamic_fr009_avg_rank_111_bf16.safetensors"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if lower.contains("ltx") && lower.contains("lora") {
|
||||
aliases.push(trimmed.replace('-', "_"));
|
||||
}
|
||||
|
||||
aliases.sort();
|
||||
aliases.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
aliases
|
||||
}
|
||||
|
||||
pub(super) fn model_payload_item_with_name(source: &Value, name: String) -> Value {
|
||||
match source {
|
||||
Value::Object(object) => {
|
||||
let mut object = object.clone();
|
||||
object.insert("name".to_string(), Value::String(name));
|
||||
object
|
||||
.entry("pathIndex".to_string())
|
||||
.or_insert_with(|| Value::Number(0.into()));
|
||||
Value::Object(object)
|
||||
}
|
||||
_ => json!({ "name": name, "pathIndex": 0 }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn merge_model_payload_items(primary: Vec<Value>, extra: Vec<Value>) -> Value {
|
||||
let mut seen = HashSet::<String>::new();
|
||||
let mut items = Vec::with_capacity(primary.len() + extra.len());
|
||||
for item in primary.into_iter().chain(extra) {
|
||||
let Some(name) = normalize_model_item_name(&item) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(name.to_ascii_lowercase()) {
|
||||
let aliases = workflow_model_display_aliases(&name);
|
||||
items.push(match item {
|
||||
Value::String(_) => json!({ "name": name.clone(), "pathIndex": 0 }),
|
||||
Value::Object(mut object) => {
|
||||
object
|
||||
.entry("name".to_string())
|
||||
.or_insert_with(|| Value::String(name.clone()));
|
||||
object
|
||||
.entry("pathIndex".to_string())
|
||||
.or_insert_with(|| Value::Number(0.into()));
|
||||
Value::Object(object)
|
||||
}
|
||||
_ => json!({ "name": name.clone(), "pathIndex": 0 }),
|
||||
});
|
||||
let source = items.last().cloned().unwrap_or_else(|| json!({}));
|
||||
for alias in aliases {
|
||||
if seen.insert(alias.to_ascii_lowercase()) {
|
||||
items.push(model_payload_item_with_name(&source, alias));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_model_folder_payload(value: &mut Value) {
|
||||
let Some(items) = value.as_array_mut() else {
|
||||
return;
|
||||
};
|
||||
for item in items {
|
||||
if let Some(name) = item.as_str().map(str::to_string) {
|
||||
*item = json!({ "name": name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
use super::auth::{auth_caller, caller_user_id};
|
||||
use super::queue::repair_native_prompt_payload;
|
||||
use super::{
|
||||
auth_caller_optional, build_query_string, safe_view_path, user_home, workspace_kind_from_mime,
|
||||
CachedJson, PromptPayload, UploadQuery, WorkflowCaller, NATIVE_WORKFLOW_CACHE_MAX_AGE,
|
||||
NATIVE_WORKFLOW_JSON_CACHE,
|
||||
};
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::workspace::{self, WorkspaceAssetInput},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{multipart::Multipart, FromRequest, Path, Query, State},
|
||||
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
pub(super) async fn workflow_cache_status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let base = native_workflow_base(&state);
|
||||
let now = Instant::now();
|
||||
let mut items = NATIVE_WORKFLOW_JSON_CACHE
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(key, _)| key.starts_with(&base))
|
||||
.map(|(key, item)| {
|
||||
json!({
|
||||
"key": key,
|
||||
"ageSecs": now.duration_since(item.inserted_at).as_secs(),
|
||||
"kind": workflow_cache_kind_from_key(key),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| {
|
||||
a.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.cmp(&b.get("key").and_then(Value::as_str))
|
||||
});
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"backend": if state.config.comfyui.uses_runninghub_backend() {
|
||||
"runninghub"
|
||||
} else if state.config.comfyui.uses_cloud_backend() {
|
||||
"cloud"
|
||||
} else if state.config.comfyui.uses_native_backend() {
|
||||
"external"
|
||||
} else {
|
||||
"boosapi"
|
||||
},
|
||||
"baseUrl": base,
|
||||
"items": items,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn upload_image(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<UploadQuery>,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
return upload_image_to_native_backend(&state, &headers, body).await;
|
||||
}
|
||||
let user_id = caller_user_id(&caller);
|
||||
let multipart = Multipart::from_request(
|
||||
axum::http::Request::builder()
|
||||
.method(Method::POST)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.body(axum::body::Body::from(body))
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
upload_image_to_local_backend(user_id, query, multipart).await
|
||||
}
|
||||
|
||||
async fn upload_image_to_local_backend(
|
||||
user_id: Option<i64>,
|
||||
query: UploadQuery,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Response> {
|
||||
let mut image_name: Option<String> = None;
|
||||
let mut image_bytes: Option<Vec<u8>> = None;
|
||||
let mut subfolder = query.subfolder.unwrap_or_default();
|
||||
let mut file_type = query.file_type.unwrap_or_else(|| "input".to_string());
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "image" {
|
||||
image_name = field.file_name().map(str::to_string);
|
||||
image_bytes = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?
|
||||
.to_vec(),
|
||||
);
|
||||
} else if name == "subfolder" {
|
||||
subfolder = field.text().await.unwrap_or_default();
|
||||
} else if name == "type" {
|
||||
file_type = field.text().await.unwrap_or_else(|_| "input".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(bytes) = image_bytes else {
|
||||
return Err(AppError::BadRequest("image is required".into()));
|
||||
};
|
||||
let file_name =
|
||||
image_name.unwrap_or_else(|| format!("upload-{}.png", uuid::Uuid::new_v4().simple()));
|
||||
let root = user_home(user_id).join("userdata").join(&file_type);
|
||||
let dir = if subfolder.trim().is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
root.join(safe_view_path(&subfolder))
|
||||
};
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
let path = dir.join(&file_name);
|
||||
tokio::fs::write(&path, bytes)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"name": file_name,
|
||||
"subfolder": subfolder,
|
||||
"type": file_type,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn upload_image_to_native_backend(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
if body.is_empty() {
|
||||
return Err(AppError::BadRequest("empty upload request body".into()));
|
||||
}
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| {
|
||||
value
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("multipart/form-data")
|
||||
})
|
||||
.ok_or_else(|| AppError::BadRequest("multipart/form-data content-type is required".into()))?
|
||||
.to_string();
|
||||
let response = native_workflow_request(state, Method::POST, "/upload/image", None)
|
||||
.header(reqwest::header::CONTENT_TYPE, content_type)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI upload failed: {err}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
if !status.is_success() {
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
tracing::warn!(status = status.as_u16(), body = %body, "Workflow native upload failed");
|
||||
}
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) fn timestamp_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn native_workflow_enabled(state: &AppState) -> bool {
|
||||
state.config.comfyui.uses_native_backend()
|
||||
}
|
||||
|
||||
pub(super) fn native_workflow_metadata_ttl(state: &AppState) -> Duration {
|
||||
if state.config.comfyui.uses_runninghub_backend() {
|
||||
Duration::from_secs(21600)
|
||||
} else {
|
||||
Duration::from_secs(300)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn native_workflow_status_ttl(state: &AppState) -> Duration {
|
||||
if state.config.comfyui.uses_runninghub_backend() {
|
||||
Duration::from_secs(120)
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn native_workflow_base(state: &AppState) -> String {
|
||||
state.config.comfyui.target_base_url()
|
||||
}
|
||||
|
||||
fn native_workflow_http_path(state: &AppState, path: &str) -> String {
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
format!(
|
||||
"/api{}",
|
||||
path.strip_prefix('/')
|
||||
.map(|value| format!("/{value}"))
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
)
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn native_workflow_request(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
native_workflow_base(state),
|
||||
native_workflow_http_path(state, path)
|
||||
);
|
||||
let reqwest_method =
|
||||
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||
let mut request = client.request(reqwest_method, url);
|
||||
if let Some(api_key) = state
|
||||
.config
|
||||
.comfyui
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request
|
||||
.header("X-API-Key", api_key)
|
||||
.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_json(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let response = native_workflow_request(state, method, path, body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(value.to_string()));
|
||||
}
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_json_cached(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
ttl: Duration,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if method != Method::GET || body.is_some() || ttl.is_zero() {
|
||||
return native_workflow_json(state, method, path, body).await;
|
||||
}
|
||||
|
||||
let cache_key = format!(
|
||||
"{}|{}|{}",
|
||||
native_workflow_base(state),
|
||||
method.as_str(),
|
||||
native_workflow_http_path(state, path)
|
||||
);
|
||||
let now = Instant::now();
|
||||
if let Some(cached) = NATIVE_WORKFLOW_JSON_CACHE.lock().get(&cache_key).cloned() {
|
||||
if now.duration_since(cached.inserted_at) <= ttl {
|
||||
return Ok(Json(cached.value));
|
||||
}
|
||||
}
|
||||
|
||||
let value = match native_workflow_json(state, method, path, None).await {
|
||||
Ok(value) => value.0,
|
||||
Err(err) => {
|
||||
if let Some(cached) = NATIVE_WORKFLOW_JSON_CACHE.lock().get(&cache_key).cloned() {
|
||||
tracing::warn!(
|
||||
path,
|
||||
error = %err,
|
||||
"using stale ComfyUI workflow metadata cache after refresh failure"
|
||||
);
|
||||
return Ok(Json(cached.value));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let mut cache = NATIVE_WORKFLOW_JSON_CACHE.lock();
|
||||
if cache.len() > 64 {
|
||||
cache.retain(|_, item| {
|
||||
now.duration_since(item.inserted_at) <= NATIVE_WORKFLOW_CACHE_MAX_AGE
|
||||
});
|
||||
}
|
||||
cache.insert(
|
||||
cache_key,
|
||||
CachedJson {
|
||||
inserted_at: now,
|
||||
value: value.clone(),
|
||||
},
|
||||
);
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
fn workflow_cache_kind_from_key(key: &str) -> &'static str {
|
||||
if key.contains("/object_info") {
|
||||
"object_info"
|
||||
} else if key.contains("/experiment/models") {
|
||||
"models"
|
||||
} else if key.contains("/system_stats") {
|
||||
"status"
|
||||
} else {
|
||||
"json"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_response(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Response> {
|
||||
let response = native_workflow_request(state, method, path, body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_view_response(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
params: &HashMap<String, String>,
|
||||
) -> AppResult<Response> {
|
||||
native_workflow_file_response(state, caller, "/view", params).await
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_video_response(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
params: &HashMap<String, String>,
|
||||
) -> AppResult<Response> {
|
||||
native_workflow_file_response(state, caller, "/viewvideo", params).await
|
||||
}
|
||||
|
||||
async fn native_workflow_file_response(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
path: &str,
|
||||
params: &HashMap<String, String>,
|
||||
) -> AppResult<Response> {
|
||||
let query = build_query_string(params);
|
||||
let response = native_workflow_request(state, Method::GET, &format!("{path}{query}"), None)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
if status.is_success() && matches!(path, "/view" | "/viewvideo") {
|
||||
archive_native_workflow_asset(state, caller, path, params, &content_type, bytes.as_ref())
|
||||
.await;
|
||||
}
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn archive_native_workflow_asset(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
path: &str,
|
||||
params: &HashMap<String, String>,
|
||||
content_type: &str,
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let file_type = params.get("type").map(String::as_str).unwrap_or("").trim();
|
||||
if file_type != "output" {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = workspace_kind_from_mime(content_type) else {
|
||||
return;
|
||||
};
|
||||
let filename = params
|
||||
.get("filename")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if filename.is_empty() || bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let user_id = caller_user_id(caller);
|
||||
let source = "workflow-studio";
|
||||
let already_exists = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_assets
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
(? = 1 AND user_id IS NULL)
|
||||
OR (? = 0 AND user_id = ?)
|
||||
)
|
||||
AND source = ?
|
||||
AND json_extract(metadata, '$.workflowUi.filename') = ?
|
||||
AND json_extract(metadata, '$.workflowUi.type') = ?
|
||||
AND json_extract(metadata, '$.workflowUi.subfolder') = ?",
|
||||
)
|
||||
.bind(user_id.is_none())
|
||||
.bind(user_id.is_none())
|
||||
.bind(user_id)
|
||||
.bind(source)
|
||||
.bind(filename)
|
||||
.bind(file_type)
|
||||
.bind(params.get("subfolder").map(String::as_str).unwrap_or(""))
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if already_exists > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let input = WorkspaceAssetInput {
|
||||
user_id,
|
||||
source: source.to_string(),
|
||||
kind: kind.to_string(),
|
||||
title: Some(filename.chars().take(80).collect()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
remote_url: None,
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"workflowUi": {
|
||||
"filename": filename,
|
||||
"type": file_type,
|
||||
"subfolder": params.get("subfolder").map(String::as_str).unwrap_or(""),
|
||||
"endpoint": path,
|
||||
}
|
||||
}),
|
||||
};
|
||||
if let Err(err) = workspace::record_bytes_asset(state, input, bytes, content_type).await {
|
||||
tracing::warn!(%err, filename, "failed to archive workflow-ui asset into workspace");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn native_workflow_api_proxy(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
if !native_workflow_enabled(&state) {
|
||||
return Err(AppError::NotFound("Workflow API route not found".into()));
|
||||
}
|
||||
let target = match uri.query() {
|
||||
Some(query) => format!("/{path}?{query}"),
|
||||
None => format!("/{path}"),
|
||||
};
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
return native_workflow_raw_response(&state, method, &target, &headers, body).await;
|
||||
}
|
||||
let body = if body.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
serde_json::from_slice::<Value>(&body)
|
||||
.unwrap_or_else(|_| json!({ "body": String::from_utf8_lossy(&body).to_string() })),
|
||||
)
|
||||
};
|
||||
native_workflow_response(&state, method, &target, body).await
|
||||
}
|
||||
|
||||
async fn native_workflow_raw_response(
|
||||
state: &AppState,
|
||||
method: Method,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
let is_prompt_post = method == Method::POST && path.split('?').next() == Some("/prompt");
|
||||
let mut request = native_workflow_request(state, method, path, None);
|
||||
if let Some(content_type) = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
if let Some(accept) = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
request = request.header(reqwest::header::ACCEPT, accept);
|
||||
}
|
||||
if !body.is_empty() {
|
||||
if is_prompt_post {
|
||||
if let Ok(mut value) = serde_json::from_slice::<Value>(&body) {
|
||||
repair_native_prompt_body_value(state, &mut value).await;
|
||||
request = request.json(&value);
|
||||
} else {
|
||||
request = request.body(body);
|
||||
}
|
||||
} else {
|
||||
request = request.body(body);
|
||||
}
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
|
||||
let status = response.status();
|
||||
let response_headers = response.headers().clone();
|
||||
let content_type = response_headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
|
||||
.header(header::CONTENT_TYPE, content_type);
|
||||
for name in [
|
||||
header::CACHE_CONTROL,
|
||||
header::ETAG,
|
||||
header::LAST_MODIFIED,
|
||||
header::LOCATION,
|
||||
header::SET_COOKIE,
|
||||
header::WWW_AUTHENTICATE,
|
||||
] {
|
||||
if let Some(value) = response_headers
|
||||
.get(name.as_str())
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| HeaderValue::from_str(value).ok())
|
||||
{
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
|
||||
}
|
||||
|
||||
async fn repair_native_prompt_body_value(state: &AppState, value: &mut Value) {
|
||||
if let Ok(mut payload) = serde_json::from_value::<PromptPayload>(value.clone()) {
|
||||
repair_native_prompt_payload(state, &mut payload).await;
|
||||
if let Ok(repaired) = serde_json::to_value(payload) {
|
||||
*value = repaired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn upload_mask(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
upload_image(
|
||||
State(state),
|
||||
headers,
|
||||
Query(UploadQuery {
|
||||
subfolder: None,
|
||||
file_type: Some("input".into()),
|
||||
}),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
workflow::storage,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
|
||||
|
||||
use super::auth::{auth_caller, caller_is_admin, caller_user_id};
|
||||
use super::node_defs::normalize_prompt_payload;
|
||||
use super::{
|
||||
auth_caller_optional, guess_mime_from_path, inject_native_prompt_cloud_api_key,
|
||||
native_workflow_json, native_workflow_json_cached, native_workflow_metadata_ttl,
|
||||
native_workflow_response, native_workflow_view_response, prompt_contains_boosapi_nodes,
|
||||
prompt_error_response, publish_run_events, repair_dynamic_combo_nodes,
|
||||
repair_resize_image_mask_nodes, repair_text_generate_ltx2_prompt_nodes, repair_wan2_api_nodes,
|
||||
run_outputs_for_comfy, safe_view_path, simple_urlencode, user_home, workflow_context,
|
||||
workflow_graph_to_comfy_workflow, workflow_progress_callback_with_run_sender,
|
||||
workflow_run_to_history, workflow_run_to_history_task, workflow_run_to_queue_task,
|
||||
HistoryQuery, PromptPayload, QueueMutation, WorkflowCaller,
|
||||
};
|
||||
|
||||
pub(super) async fn queue_status(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
return native_workflow_json(&state, Method::GET, "/prompt", None).await;
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"status_str": "success",
|
||||
"completed": true,
|
||||
"messages": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn queue(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
return native_workflow_json(&state, Method::GET, "/queue", None).await;
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let rows = storage::list_runs(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
25,
|
||||
)
|
||||
.await?;
|
||||
let running = rows
|
||||
.into_iter()
|
||||
.filter(|run| run.status == "running")
|
||||
.map(workflow_run_to_queue_task)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"Running": running,
|
||||
"Pending": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn queue_mutate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(_payload): Json<QueueMutation>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let value = serde_json::to_value(&_payload).unwrap_or_else(|_| json!({}));
|
||||
return native_workflow_json(&state, Method::POST, "/queue", Some(value)).await;
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"Running": [],
|
||||
"Pending": []
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let path = format!("/history?max_items={}", query.max_items);
|
||||
return native_workflow_json(&state, Method::GET, &path, None).await;
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let rows = storage::list_runs(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
query.max_items,
|
||||
)
|
||||
.await?;
|
||||
let history = rows
|
||||
.into_iter()
|
||||
.map(workflow_run_to_history_task)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({ "History": history })))
|
||||
}
|
||||
|
||||
pub(super) async fn history_mutate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<QueueMutation>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let value = serde_json::to_value(&payload).unwrap_or_else(|_| json!({}));
|
||||
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
|
||||
}
|
||||
let affected = if payload.clear {
|
||||
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await?
|
||||
as usize
|
||||
} else {
|
||||
storage::delete_runs(
|
||||
&state,
|
||||
&payload.delete,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"deleted": affected
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn history_by_id(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let path = format!("/history/{}", simple_urlencode(&id));
|
||||
return native_workflow_json(&state, Method::GET, &path, None).await;
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(workflow_run_to_history(run)))
|
||||
}
|
||||
|
||||
pub(super) async fn queue_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(mut payload): Json<PromptPayload>,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
repair_native_prompt_payload(&state, &mut payload).await;
|
||||
return native_workflow_response(
|
||||
&state,
|
||||
Method::POST,
|
||||
"/prompt",
|
||||
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if prompt_contains_boosapi_nodes(&payload.prompt) {
|
||||
return queue_boosapi_prompt(state, caller, payload).await;
|
||||
}
|
||||
repair_native_prompt_payload(&state, &mut payload).await;
|
||||
return native_workflow_response(
|
||||
&state,
|
||||
Method::POST,
|
||||
"/prompt",
|
||||
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
queue_boosapi_prompt(state, caller, payload).await
|
||||
}
|
||||
|
||||
pub(super) async fn repair_native_prompt_payload(state: &AppState, payload: &mut PromptPayload) {
|
||||
inject_native_prompt_cloud_api_key(state, payload);
|
||||
let object_info = native_workflow_json_cached(
|
||||
state,
|
||||
Method::GET,
|
||||
"/object_info",
|
||||
None,
|
||||
native_workflow_metadata_ttl(state),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.map(|value| value.0);
|
||||
if let Some(object_info) = object_info.as_ref() {
|
||||
repair_dynamic_combo_nodes(&mut payload.prompt, object_info);
|
||||
}
|
||||
repair_resize_image_mask_nodes(&mut payload.prompt, &payload.extra_data);
|
||||
repair_text_generate_ltx2_prompt_nodes(&mut payload.prompt);
|
||||
repair_wan2_api_nodes(&mut payload.prompt);
|
||||
}
|
||||
|
||||
pub(super) async fn queue_boosapi_prompt(
|
||||
state: AppState,
|
||||
caller: WorkflowCaller,
|
||||
payload: PromptPayload,
|
||||
) -> AppResult<Response> {
|
||||
let prompt = payload.prompt.clone();
|
||||
let graph = match normalize_prompt_payload(&prompt) {
|
||||
Ok(graph) => graph,
|
||||
Err(err) => {
|
||||
return Ok(prompt_error_response("工作流无法执行", err.to_string()));
|
||||
}
|
||||
};
|
||||
if graph.nodes.is_empty() {
|
||||
return Ok(prompt_error_response(
|
||||
"画布里还没有可执行节点",
|
||||
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
|
||||
));
|
||||
}
|
||||
let name = payload
|
||||
.extra_data
|
||||
.pointer("/extra_pnginfo/workflow/name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("ComfyUI workflow");
|
||||
let comfy_workflow = payload
|
||||
.extra_data
|
||||
.pointer("/extra_pnginfo/workflow")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| workflow_graph_to_comfy_workflow(&graph));
|
||||
let workflow = storage::insert_workflow(
|
||||
&state,
|
||||
caller_user_id(&caller),
|
||||
name,
|
||||
Some("Imported from ComfyUI frontend"),
|
||||
&serde_json::to_value(&graph).unwrap_or_else(|_| json!({})),
|
||||
"private",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ctx = workflow_context(&state, &caller).await?;
|
||||
let progress_session_id = payload
|
||||
.client_id
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "boosapi-workflow".to_string());
|
||||
let (run_tx, run_rx) = mpsc::sync_channel::<String>(1);
|
||||
let progress =
|
||||
workflow_progress_callback_with_run_sender(Some(progress_session_id.clone()), Some(run_tx));
|
||||
let state_for_task = state.clone();
|
||||
let graph_value = serde_json::to_value(&graph).unwrap_or_else(|_| json!({}));
|
||||
let task_graph_value = graph_value.clone();
|
||||
let user_id = caller_user_id(&caller);
|
||||
let allow_admin = caller_is_admin(&caller);
|
||||
let session_for_task = progress_session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = crate::workflow::executor::run_workflow_with_progress(
|
||||
&state_for_task,
|
||||
workflow.id,
|
||||
task_graph_value,
|
||||
json!({}),
|
||||
ctx,
|
||||
progress,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(run_id) => {
|
||||
if let Ok(run) =
|
||||
storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await
|
||||
{
|
||||
let outputs = run_outputs_for_comfy(&run);
|
||||
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("workflow background execution failed: {err}");
|
||||
}
|
||||
}
|
||||
});
|
||||
let run_id = match tokio::task::spawn_blocking(move || run_rx.recv()).await {
|
||||
Ok(Ok(run_id)) => run_id,
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
return Ok(prompt_error_response(
|
||||
"工作流执行失败",
|
||||
"后台任务没有成功进入队列,请稍后重试。",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"prompt_id": run_id,
|
||||
"number": payload.number.unwrap_or(0),
|
||||
"node_errors": [],
|
||||
"client_id": payload.client_id,
|
||||
"front": payload.front.unwrap_or(false),
|
||||
"outputs": {},
|
||||
"workflow": comfy_workflow
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn interrupt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
return native_workflow_json(&state, Method::POST, "/interrupt", Some(json!({}))).await;
|
||||
}
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
pub(super) async fn view(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> AppResult<Response> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
return native_workflow_view_response(&state, &caller, ¶ms).await;
|
||||
}
|
||||
let file = params.get("filename").cloned().unwrap_or_default();
|
||||
if file.is_empty() {
|
||||
return Err(AppError::BadRequest("filename is required".into()));
|
||||
}
|
||||
let file_type = params
|
||||
.get("type")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "input".to_string());
|
||||
let subfolder = params.get("subfolder").cloned().unwrap_or_default();
|
||||
let user_root = user_home(caller_user_id(&caller))
|
||||
.join("userdata")
|
||||
.join(&file_type);
|
||||
let local_path = if subfolder.trim().is_empty() {
|
||||
user_root.join(&file)
|
||||
} else {
|
||||
user_root.join(safe_view_path(&subfolder)).join(&file)
|
||||
};
|
||||
if tokio::fs::metadata(&local_path).await.is_ok() {
|
||||
let bytes = tokio::fs::read(&local_path)
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
guess_mime_from_path(&local_path),
|
||||
)],
|
||||
bytes,
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT file_path, remote_url, mime_type
|
||||
FROM workspace_assets
|
||||
WHERE file_name = ? OR public_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&file)
|
||||
.bind(&file)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(AppError::NotFound("Workspace asset not found".into()));
|
||||
};
|
||||
let file_path: Option<String> = row.try_get("file_path").ok();
|
||||
if let Some(path) = file_path {
|
||||
let path_buf = PathBuf::from(path);
|
||||
match tokio::fs::read(&path_buf).await {
|
||||
Ok(bytes) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
guess_mime_from_path(&path_buf),
|
||||
)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
Err(err) => return Err(AppError::UpstreamError(err.to_string())),
|
||||
}
|
||||
}
|
||||
let remote_url: Option<String> = row.try_get("remote_url").ok();
|
||||
if let Some(url) = remote_url
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Ok((
|
||||
StatusCode::TEMPORARY_REDIRECT,
|
||||
[(axum::http::header::LOCATION, url.to_string())],
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(AppError::NotFound(
|
||||
remote_url.unwrap_or_else(|| "asset unavailable".into()),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn internal_logs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Html<String>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
Ok(Html("".to_string()))
|
||||
}
|
||||
|
||||
pub(super) async fn internal_logs_raw(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
Ok(Json(
|
||||
json!({ "size": { "cols": 0, "row": 0 }, "entries": [] }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
use super::workflow_cloud_api_key;
|
||||
use super::PromptPayload;
|
||||
use crate::AppState;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(super) fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) {
|
||||
if !state.config.comfyui.uses_cloud_backend() {
|
||||
return;
|
||||
}
|
||||
let Some(api_key) = workflow_cloud_api_key(&state.config.comfyui) else {
|
||||
return;
|
||||
};
|
||||
let Value::Object(extra_data) = &mut payload.extra_data else {
|
||||
payload.extra_data = json!({ "api_key_comfy_org": api_key });
|
||||
return;
|
||||
};
|
||||
extra_data.insert(
|
||||
"api_key_comfy_org".to_string(),
|
||||
Value::String(api_key.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) {
|
||||
let Some(prompt_nodes) = prompt.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let workflow = extra_data
|
||||
.pointer("/extra_pnginfo/workflow")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let mut workflow_nodes = Vec::<Value>::new();
|
||||
collect_workflow_nodes(&workflow, &mut workflow_nodes);
|
||||
if workflow_nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let workflow_by_id = workflow_nodes
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
let id = node.get("id")?;
|
||||
Some((id_to_prompt_key(id), node))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for (node_id, node) in prompt_nodes.iter_mut() {
|
||||
let Some(object) = node.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if object
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|class_type| class_type != "ResizeImageMaskNode")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
let crop_is_invalid = inputs
|
||||
.get("resize_type.crop")
|
||||
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
|
||||
if !crop_is_invalid {
|
||||
continue;
|
||||
}
|
||||
let Some(workflow_node) = workflow_by_id
|
||||
.get(node_id)
|
||||
.or_else(|| prompt_subgraph_node_id(node_id).and_then(|id| workflow_by_id.get(id)))
|
||||
else {
|
||||
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
|
||||
continue;
|
||||
};
|
||||
let Some(values) = workflow_node
|
||||
.get("widgets_values")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
|
||||
continue;
|
||||
};
|
||||
if values
|
||||
.first()
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "scale dimensions")
|
||||
&& values.len() >= 4
|
||||
{
|
||||
inputs.insert(
|
||||
"resize_type".to_string(),
|
||||
Value::String("scale dimensions".to_string()),
|
||||
);
|
||||
inputs.insert("resize_type.width".to_string(), values[1].clone());
|
||||
inputs.insert("resize_type.height".to_string(), values[2].clone());
|
||||
inputs.insert("resize_type.crop".to_string(), values[3].clone());
|
||||
if let Some(method) = values.get(4) {
|
||||
inputs.insert("resize_type.method".to_string(), method.clone());
|
||||
}
|
||||
tracing::warn!(node_id = %node_id, "repaired ResizeImageMaskNode dynamic widget prompt inputs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_resize_image_mask_node_inputs_without_workflow(
|
||||
node_id: &str,
|
||||
inputs: &mut Map<String, Value>,
|
||||
) {
|
||||
let crop_is_invalid = inputs
|
||||
.get("resize_type.crop")
|
||||
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
|
||||
if !crop_is_invalid {
|
||||
return;
|
||||
}
|
||||
inputs
|
||||
.entry("resize_type".to_string())
|
||||
.or_insert_with(|| Value::String("scale dimensions".to_string()));
|
||||
inputs.insert(
|
||||
"resize_type.crop".to_string(),
|
||||
Value::String("center".to_string()),
|
||||
);
|
||||
tracing::warn!(
|
||||
node_id = %node_id,
|
||||
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
|
||||
let Some(prompt_nodes) = prompt.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for (node_id, node) in prompt_nodes.iter_mut() {
|
||||
let Some(object) = node.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if object
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|class_type| class_type != "TextGenerateLTX2Prompt")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
let repetition_penalty_invalid = inputs
|
||||
.get("sampling_mode.repetition_penalty")
|
||||
.and_then(Value::as_f64)
|
||||
.is_some_and(|value| !(0.0..=5.0).contains(&value));
|
||||
if !repetition_penalty_invalid {
|
||||
continue;
|
||||
}
|
||||
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
|
||||
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
|
||||
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
|
||||
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
|
||||
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
|
||||
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
|
||||
inputs
|
||||
.entry("sampling_mode.seed".to_string())
|
||||
.or_insert_with(|| json!(0));
|
||||
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
||||
tracing::warn!(
|
||||
node_id = %node_id,
|
||||
"repaired TextGenerateLTX2Prompt dynamic sampling prompt inputs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DynamicComboField {
|
||||
name: String,
|
||||
value_type: String,
|
||||
options: Vec<String>,
|
||||
default: Option<Value>,
|
||||
min: Option<f64>,
|
||||
max: Option<f64>,
|
||||
}
|
||||
|
||||
pub(super) fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) {
|
||||
let Some(prompt_nodes) = prompt.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for (node_id, node) in prompt_nodes.iter_mut() {
|
||||
let Some(object) = node.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let class_type = object
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if class_type.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dynamic_groups = dynamic_combo_groups_for_node(object_info, &class_type);
|
||||
if dynamic_groups.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
let mut repaired_groups = Vec::<String>::new();
|
||||
for (group_name, option_key, fields) in dynamic_groups {
|
||||
if repair_dynamic_combo_group(inputs, &group_name, &option_key, &fields) {
|
||||
repaired_groups.push(group_name);
|
||||
}
|
||||
}
|
||||
if !repaired_groups.is_empty() {
|
||||
tracing::warn!(
|
||||
node_id = %node_id,
|
||||
class_type = %class_type,
|
||||
groups = ?repaired_groups,
|
||||
"repaired ComfyUI dynamic combo prompt inputs from object_info schema"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_combo_groups_for_node(
|
||||
object_info: &Value,
|
||||
class_type: &str,
|
||||
) -> Vec<(String, String, Vec<DynamicComboField>)> {
|
||||
let Some(node) = object_info.get(class_type) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut groups = Vec::new();
|
||||
for section in ["required", "optional"] {
|
||||
let Some(inputs) = node
|
||||
.pointer(&format!("/input/{section}"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (name, input) in inputs {
|
||||
let Some(input_type) = input.get(0).and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if !input_type.starts_with("COMFY_DYNAMICCOMBO") {
|
||||
continue;
|
||||
}
|
||||
let Some(option) = input
|
||||
.get(1)
|
||||
.and_then(|config| config.get("options"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|options| options.first())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let option_key = option
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let fields = dynamic_combo_fields_from_option(option);
|
||||
if !fields.is_empty() {
|
||||
groups.push((name.clone(), option_key, fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
fn dynamic_combo_fields_from_option(option: &Value) -> Vec<DynamicComboField> {
|
||||
let Some(required) = option
|
||||
.pointer("/inputs/required")
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
required
|
||||
.iter()
|
||||
.filter_map(|(name, spec)| {
|
||||
let value_type = spec.get(0).and_then(Value::as_str)?.to_string();
|
||||
let config = spec.get(1).and_then(Value::as_object);
|
||||
let options = config
|
||||
.and_then(|config| config.get("options"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(DynamicComboField {
|
||||
name: name.clone(),
|
||||
value_type,
|
||||
options,
|
||||
default: config.and_then(|config| config.get("default")).cloned(),
|
||||
min: config
|
||||
.and_then(|config| config.get("min"))
|
||||
.and_then(Value::as_f64),
|
||||
max: config
|
||||
.and_then(|config| config.get("max"))
|
||||
.and_then(Value::as_f64),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn repair_dynamic_combo_group(
|
||||
inputs: &mut Map<String, Value>,
|
||||
group_name: &str,
|
||||
option_key: &str,
|
||||
fields: &[DynamicComboField],
|
||||
) -> bool {
|
||||
if fields.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut values = Vec::<Value>::new();
|
||||
if let Some(value) = inputs.get(group_name).cloned() {
|
||||
values.push(value);
|
||||
}
|
||||
for field in fields {
|
||||
if let Some(value) = inputs.get(&format!("{group_name}.{}", field.name)).cloned() {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let invalid = fields.iter().any(|field| {
|
||||
let key = format!("{group_name}.{}", field.name);
|
||||
inputs
|
||||
.get(&key)
|
||||
.is_some_and(|value| !dynamic_combo_value_matches_field(value, field))
|
||||
});
|
||||
if !invalid {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut used = vec![false; values.len()];
|
||||
if !option_key.is_empty() {
|
||||
inputs.insert(
|
||||
group_name.to_string(),
|
||||
Value::String(option_key.to_string()),
|
||||
);
|
||||
for (idx, value) in values.iter().enumerate() {
|
||||
if value.as_str() == Some(option_key) {
|
||||
used[idx] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for field in fields {
|
||||
let key = format!("{group_name}.{}", field.name);
|
||||
let selected_idx = values.iter().enumerate().find_map(|(idx, value)| {
|
||||
(!used[idx] && dynamic_combo_value_matches_field(value, field)).then_some(idx)
|
||||
});
|
||||
let repaired = selected_idx
|
||||
.map(|idx| {
|
||||
used[idx] = true;
|
||||
normalize_dynamic_combo_value(&values[idx], field)
|
||||
})
|
||||
.unwrap_or_else(|| default_dynamic_combo_value(field));
|
||||
inputs.insert(key, repaired);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -> bool {
|
||||
match field.value_type.as_str() {
|
||||
"STRING" => value.as_str().is_some(),
|
||||
"COMBO" => value
|
||||
.as_str()
|
||||
.is_some_and(|text| field.options.iter().any(|option| option == text)),
|
||||
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
|
||||
field.min.is_none_or(|min| (number as f64) >= min)
|
||||
&& field.max.is_none_or(|max| (number as f64) <= max)
|
||||
}),
|
||||
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
|
||||
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
|
||||
}),
|
||||
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_dynamic_combo_value(value: &Value, field: &DynamicComboField) -> Value {
|
||||
match field.value_type.as_str() {
|
||||
"INT" => dynamic_combo_value_as_i64(value)
|
||||
.map(|number| json!(clamp_i64_to_field(number, field)))
|
||||
.unwrap_or_else(|| default_dynamic_combo_value(field)),
|
||||
"FLOAT" => dynamic_combo_value_as_f64(value)
|
||||
.map(|number| json!(clamp_f64_to_field(number, field)))
|
||||
.unwrap_or_else(|| default_dynamic_combo_value(field)),
|
||||
"BOOLEAN" => dynamic_combo_value_as_bool(value)
|
||||
.map(Value::Bool)
|
||||
.unwrap_or_else(|| default_dynamic_combo_value(field)),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dynamic_combo_value(field: &DynamicComboField) -> Value {
|
||||
if let Some(default) = &field.default {
|
||||
return normalize_dynamic_combo_value(default, field);
|
||||
}
|
||||
match field.value_type.as_str() {
|
||||
"COMBO" => field
|
||||
.options
|
||||
.first()
|
||||
.cloned()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"STRING" => Value::String(String::new()),
|
||||
"INT" => json!(field.min.unwrap_or(0.0).ceil() as i64),
|
||||
"FLOAT" => json!(field.min.unwrap_or(0.0)),
|
||||
"BOOLEAN" => Value::Bool(false),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||
.or_else(|| value.as_f64().map(|number| number.round() as i64))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<f64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
|
||||
value.as_bool().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => Some(true),
|
||||
"false" | "0" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
|
||||
let min = field
|
||||
.min
|
||||
.map(|value| value.ceil() as i64)
|
||||
.unwrap_or(i64::MIN);
|
||||
let max = field
|
||||
.max
|
||||
.map(|value| value.floor() as i64)
|
||||
.unwrap_or(i64::MAX);
|
||||
value.clamp(min, max)
|
||||
}
|
||||
|
||||
fn clamp_f64_to_field(value: f64, field: &DynamicComboField) -> f64 {
|
||||
let min = field.min.unwrap_or(f64::NEG_INFINITY);
|
||||
let max = field.max.unwrap_or(f64::INFINITY);
|
||||
value.clamp(min, max)
|
||||
}
|
||||
pub(super) fn repair_wan2_api_nodes(prompt: &mut Value) {
|
||||
let Some(prompt_nodes) = prompt.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for (node_id, node) in prompt_nodes.iter_mut() {
|
||||
let Some(object) = node.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let class_type = object
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let field_names = match class_type.as_str() {
|
||||
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
|
||||
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
|
||||
"prompt",
|
||||
"negative_prompt",
|
||||
"resolution",
|
||||
"ratio",
|
||||
"duration",
|
||||
][..],
|
||||
_ => continue,
|
||||
};
|
||||
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
if !wan2_model_inputs_look_shifted(inputs) {
|
||||
continue;
|
||||
}
|
||||
repair_wan2_model_inputs(inputs, field_names);
|
||||
tracing::warn!(
|
||||
node_id = %node_id,
|
||||
class_type = %class_type,
|
||||
"repaired Wan2 dynamic model prompt inputs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn wan2_model_inputs_look_shifted(inputs: &Map<String, Value>) -> bool {
|
||||
inputs
|
||||
.get("model.resolution")
|
||||
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
|
||||
|| inputs.get("model.ratio").is_some_and(|value| {
|
||||
!matches!(
|
||||
value.as_str(),
|
||||
Some("16:9" | "9:16" | "1:1" | "4:3" | "3:4")
|
||||
)
|
||||
})
|
||||
|| inputs
|
||||
.get("model.duration")
|
||||
.is_some_and(|value| value.as_i64().is_none())
|
||||
}
|
||||
|
||||
fn repair_wan2_model_inputs(inputs: &mut Map<String, Value>, field_names: &[&str]) {
|
||||
let mut values = field_names
|
||||
.iter()
|
||||
.filter_map(|name| inputs.remove(&format!("model.{name}")))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(model) = inputs.remove("model") {
|
||||
values.insert(0, model);
|
||||
}
|
||||
|
||||
let prompt = values
|
||||
.iter()
|
||||
.find_map(|value| {
|
||||
value.as_str().and_then(|text| {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.len() > 20 && !is_wan2_resolution(trimmed) && !is_wan2_ratio(trimmed) {
|
||||
Some(Value::String(text.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Value::String(String::new()));
|
||||
let negative_prompt = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.find(|text| {
|
||||
let trimmed = text.trim();
|
||||
!trimmed.is_empty()
|
||||
&& trimmed.len() <= 20
|
||||
&& !is_wan2_model_key(trimmed)
|
||||
&& !is_wan2_resolution(trimmed)
|
||||
&& !is_wan2_ratio(trimmed)
|
||||
})
|
||||
.map(|text| Value::String(text.to_string()))
|
||||
.unwrap_or_else(|| Value::String(String::new()));
|
||||
let resolution = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.find(|text| is_wan2_resolution(text.trim()))
|
||||
.map(|text| Value::String(text.trim().to_string()))
|
||||
.unwrap_or_else(|| Value::String("720P".to_string()));
|
||||
let ratio = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.find(|text| is_wan2_ratio(text.trim()))
|
||||
.map(|text| Value::String(text.trim().to_string()))
|
||||
.unwrap_or_else(|| Value::String("16:9".to_string()));
|
||||
let duration = values
|
||||
.iter()
|
||||
.find_map(|value| {
|
||||
value.as_i64().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||
})
|
||||
})
|
||||
.map(|value| value.clamp(2, 15))
|
||||
.unwrap_or(5);
|
||||
|
||||
inputs.insert(
|
||||
"model".to_string(),
|
||||
infer_wan2_model_key(field_names, &values),
|
||||
);
|
||||
inputs.insert("model.prompt".to_string(), prompt);
|
||||
inputs.insert("model.negative_prompt".to_string(), negative_prompt);
|
||||
inputs.insert("model.resolution".to_string(), resolution);
|
||||
if field_names.contains(&"ratio") {
|
||||
inputs.insert("model.ratio".to_string(), ratio);
|
||||
}
|
||||
inputs.insert("model.duration".to_string(), json!(duration));
|
||||
}
|
||||
|
||||
fn infer_wan2_model_key(field_names: &[&str], values: &[Value]) -> Value {
|
||||
if let Some(model) = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.find(|value| is_wan2_model_key(value.trim()))
|
||||
{
|
||||
return Value::String(model.trim().to_string());
|
||||
}
|
||||
if field_names.contains(&"ratio") {
|
||||
Value::String("wan2.7-t2v".to_string())
|
||||
} else {
|
||||
Value::String("wan2.7-i2v".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_wan2_resolution(value: &str) -> bool {
|
||||
matches!(value, "720P" | "1080P")
|
||||
}
|
||||
|
||||
fn is_wan2_ratio(value: &str) -> bool {
|
||||
matches!(value, "16:9" | "9:16" | "1:1" | "4:3" | "3:4")
|
||||
}
|
||||
|
||||
fn is_wan2_model_key(value: &str) -> bool {
|
||||
matches!(value, "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v")
|
||||
}
|
||||
|
||||
fn collect_workflow_nodes(value: &Value, nodes: &mut Vec<Value>) {
|
||||
if let Some(items) = value.get("nodes").and_then(Value::as_array) {
|
||||
nodes.extend(items.iter().cloned());
|
||||
}
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_workflow_nodes(item, nodes);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for item in object.values() {
|
||||
collect_workflow_nodes(item, nodes);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_subgraph_node_id(node_id: &str) -> Option<&str> {
|
||||
node_id
|
||||
.rsplit_once(':')
|
||||
.map(|(_, child_id)| child_id.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn id_to_prompt_key(id: &Value) -> String {
|
||||
if let Some(value) = id.as_i64() {
|
||||
return value.to_string();
|
||||
}
|
||||
id.as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use crate::workflow::models::WorkflowGraph;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::native_proxy::timestamp_ms;
|
||||
use super::workflow_node_label;
|
||||
|
||||
pub(super) fn workflow_run_to_history(run: crate::workflow::models::WorkflowRunSummary) -> Value {
|
||||
let outputs = run_outputs_for_comfy(&run);
|
||||
let status_str = if run.status == "failed" {
|
||||
"error"
|
||||
} else {
|
||||
"success"
|
||||
};
|
||||
json!({
|
||||
"prompt": workflow_run_to_prompt_tuple(&run),
|
||||
"status": {
|
||||
"status_str": status_str,
|
||||
"completed": run.status != "running",
|
||||
"messages": run_status_messages(&run)
|
||||
},
|
||||
"outputs": outputs,
|
||||
"meta": {},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workflow_run_to_history_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
|
||||
let history = workflow_run_to_history(run);
|
||||
json!({
|
||||
"taskType": "History",
|
||||
"prompt": history.get("prompt").cloned().unwrap_or_else(|| json!([])),
|
||||
"status": history.get("status").cloned().unwrap_or_else(|| json!({})),
|
||||
"outputs": history.get("outputs").cloned().unwrap_or_else(|| json!({})),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workflow_run_to_queue_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
|
||||
json!({
|
||||
"taskType": "Running",
|
||||
"prompt": workflow_run_to_prompt_tuple(&run),
|
||||
"status": {
|
||||
"status_str": "running",
|
||||
"completed": false,
|
||||
"messages": run_status_messages(&run)
|
||||
},
|
||||
"outputs": run_outputs_for_comfy(&run),
|
||||
})
|
||||
}
|
||||
|
||||
fn workflow_timestamp_ms(value: &str) -> i64 {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return timestamp_ms();
|
||||
}
|
||||
chrono::DateTime::parse_from_rfc3339(trimmed)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.or_else(|_| {
|
||||
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp_millis())
|
||||
})
|
||||
.or_else(|_| {
|
||||
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp_millis())
|
||||
})
|
||||
.unwrap_or_else(|_| timestamp_ms())
|
||||
}
|
||||
|
||||
pub(super) fn workflow_graph_to_comfy_workflow(graph: &WorkflowGraph) -> Value {
|
||||
let nodes = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
json!({
|
||||
"id": node.id,
|
||||
"type": node.node_type,
|
||||
"pos": [
|
||||
node.position.get("x").and_then(Value::as_f64).unwrap_or(80.0),
|
||||
node.position.get("y").and_then(Value::as_f64).unwrap_or(120.0)
|
||||
],
|
||||
"widgets_values": node.params,
|
||||
"properties": { "Node name for S&R": node.node_type },
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"id": graph.name.clone().unwrap_or_else(|| "boosapi-workflow".to_string()),
|
||||
"version": 0.4,
|
||||
"nodes": nodes,
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
})
|
||||
}
|
||||
|
||||
fn run_status_messages(run: &crate::workflow::models::WorkflowRunSummary) -> Vec<Value> {
|
||||
let mut messages = vec![json!(["execution_start", {
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": workflow_timestamp_ms(run.started_at.as_deref().unwrap_or(&run.created_at))
|
||||
}])];
|
||||
|
||||
if run.status == "running" {
|
||||
let completed = run
|
||||
.node_runs
|
||||
.iter()
|
||||
.filter(|node| node.status == "completed")
|
||||
.count();
|
||||
let total = run.node_runs.len().max(1);
|
||||
messages.push(json!(["progress", {
|
||||
"value": completed,
|
||||
"max": total
|
||||
}]));
|
||||
if let Some(current) = run
|
||||
.node_runs
|
||||
.iter()
|
||||
.find(|node| node.status == "running")
|
||||
.or_else(|| run.node_runs.last())
|
||||
{
|
||||
let label = workflow_node_label(¤t.node_type, ¤t.node_id);
|
||||
messages.push(json!(["executing", {
|
||||
"prompt_id": run.public_id,
|
||||
"node": current.node_id,
|
||||
"node_type": current.node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
}]));
|
||||
}
|
||||
} else if run.status == "failed" {
|
||||
let failed_node = run
|
||||
.node_runs
|
||||
.iter()
|
||||
.find(|node| node.status == "failed")
|
||||
.or_else(|| run.node_runs.last());
|
||||
messages.push(json!(["execution_error", {
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": workflow_timestamp_ms(run.completed_at.as_deref().unwrap_or(&run.created_at)),
|
||||
"node_id": failed_node.map(|node| node.node_id.as_str()).unwrap_or("workflow"),
|
||||
"node_type": failed_node.map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||
"executed": run.node_runs.iter().filter(|node| node.status == "completed").map(|node| node.node_id.clone()).collect::<Vec<_>>(),
|
||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"traceback": [],
|
||||
"current_inputs": failed_node.map(|node| node.inputs.clone()).unwrap_or_else(|| json!({})),
|
||||
"current_outputs": failed_node.and_then(|node| node.outputs.clone()).unwrap_or_else(|| json!({})),
|
||||
}]));
|
||||
} else {
|
||||
messages.push(json!(["execution_success", {
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": workflow_timestamp_ms(run.completed_at.as_deref().unwrap_or(&run.created_at))
|
||||
}]));
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(super) fn run_outputs_for_comfy(run: &crate::workflow::models::WorkflowRunSummary) -> Value {
|
||||
let mut outputs = Map::new();
|
||||
for node in &run.node_runs {
|
||||
if let Some(node_outputs) = &node.outputs {
|
||||
let media = node_output_for_comfy(node_outputs)
|
||||
.unwrap_or_else(|| json!({ "text": [workflow_text_output(node_outputs)] }));
|
||||
outputs.insert(node.node_id.clone(), media);
|
||||
}
|
||||
}
|
||||
Value::Object(outputs)
|
||||
}
|
||||
|
||||
pub(super) fn node_output_for_comfy(node_outputs: &Value) -> Option<Value> {
|
||||
let mut media = Map::new();
|
||||
if let Some(videos) = comfy_media_items(node_outputs, "comfy_videos")
|
||||
.or_else(|| comfy_media_items(node_outputs, "videos"))
|
||||
.or_else(|| comfy_media_items(node_outputs, "video"))
|
||||
{
|
||||
media.insert("videos".to_string(), videos);
|
||||
}
|
||||
if let Some(images) = comfy_media_items(node_outputs, "comfy_images")
|
||||
.or_else(|| comfy_media_items(node_outputs, "image"))
|
||||
.or_else(|| comfy_media_items(node_outputs, "images"))
|
||||
.or_else(|| comfy_media_items(node_outputs, "IMAGE"))
|
||||
{
|
||||
media.insert("images".to_string(), images);
|
||||
}
|
||||
if media.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(media))
|
||||
}
|
||||
}
|
||||
|
||||
fn comfy_media_items(node_outputs: &Value, key: &str) -> Option<Value> {
|
||||
let value = node_outputs.get(key)?;
|
||||
let items = if let Some(array) = value.as_array() {
|
||||
array.clone()
|
||||
} else {
|
||||
vec![value.clone()]
|
||||
};
|
||||
let normalized = items
|
||||
.into_iter()
|
||||
.filter_map(normalize_comfy_media_item)
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_comfy_media_item(value: Value) -> Option<Value> {
|
||||
let object = value.as_object()?;
|
||||
let filename = object
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| object.get("public_id").and_then(Value::as_str))
|
||||
.or_else(|| object.get("publicId").and_then(Value::as_str))
|
||||
.or_else(|| object.get("file_name").and_then(Value::as_str))
|
||||
.or_else(|| object.get("fileName").and_then(Value::as_str))
|
||||
.filter(|value| !value.trim().is_empty())?;
|
||||
let file_type = object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("output");
|
||||
let subfolder = object
|
||||
.get("subfolder")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let mut normalized = Map::new();
|
||||
normalized.insert("filename".to_string(), json!(filename));
|
||||
normalized.insert("subfolder".to_string(), json!(subfolder));
|
||||
normalized.insert("type".to_string(), json!(file_type));
|
||||
if let Some(format) = object.get("format").and_then(Value::as_str) {
|
||||
normalized.insert("format".to_string(), json!(format));
|
||||
}
|
||||
if let Some(frame_rate) = object.get("frame_rate").or_else(|| object.get("frameRate")) {
|
||||
normalized.insert("frame_rate".to_string(), frame_rate.clone());
|
||||
}
|
||||
Some(Value::Object(normalized))
|
||||
}
|
||||
|
||||
fn workflow_text_output(value: &Value) -> Value {
|
||||
for key in ["text", "prompt", "task_id", "file"] {
|
||||
if let Some(text) = value.get(key).and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
return json!({
|
||||
"filename": text.chars().take(80).collect::<String>(),
|
||||
"subfolder": "",
|
||||
"type": "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"filename": "workflow-result",
|
||||
"subfolder": "",
|
||||
"type": "text",
|
||||
})
|
||||
}
|
||||
|
||||
fn workflow_run_to_prompt_tuple(run: &crate::workflow::models::WorkflowRunSummary) -> Value {
|
||||
let workflow_name = run
|
||||
.workflow_name
|
||||
.as_deref()
|
||||
.or(run.workflow_public_id.as_deref())
|
||||
.unwrap_or("BoosAPI workflow");
|
||||
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
|
||||
let prompt = run
|
||||
.node_runs
|
||||
.iter()
|
||||
.map(|node| {
|
||||
(
|
||||
node.node_id.clone(),
|
||||
json!({
|
||||
"inputs": node.inputs,
|
||||
"class_type": node.node_type,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect::<Map<_, _>>();
|
||||
json!([
|
||||
run.id,
|
||||
run.public_id,
|
||||
prompt,
|
||||
{
|
||||
"extra_pnginfo": { "workflow": workflow },
|
||||
"client_id": "boosapi-workflow-studio"
|
||||
},
|
||||
run.node_runs.iter().map(|node| node.node_id.clone()).collect::<Vec<_>>()
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, Method},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::auth::{auth_caller, caller_user_id};
|
||||
use super::models::{combo_fields_from_node, workflow_route_models};
|
||||
use super::{
|
||||
build_query_string, field_names_are_equivalent, inject_boosapi_object_info,
|
||||
is_input_file_field, is_modelish_field, native_workflow_json_cached,
|
||||
native_workflow_metadata_ttl, native_workflow_request, safe_view_path, user_home,
|
||||
workflow_linked_node_ids, workflow_node_defs_with_models, workflow_validation_hints,
|
||||
workflow_widget_inputs, workflow_widget_string, WorkflowCaller, WorkflowValidateQuery,
|
||||
};
|
||||
|
||||
pub(super) async fn validate_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WorkflowValidateQuery>,
|
||||
Json(workflow): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = auth_caller(&state, &headers).await?;
|
||||
let object_info = if state.config.comfyui.uses_native_backend() {
|
||||
let mut value = native_workflow_json_cached(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/object_info",
|
||||
None,
|
||||
native_workflow_metadata_ttl(&state),
|
||||
)
|
||||
.await?
|
||||
.0;
|
||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||
inject_boosapi_object_info(&mut value, &route_models);
|
||||
value
|
||||
} else {
|
||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||
Value::Object(workflow_node_defs_with_models(&route_models, true))
|
||||
};
|
||||
let report = validate_workflow_against_object_info(
|
||||
&state,
|
||||
&caller,
|
||||
&workflow,
|
||||
&object_info,
|
||||
query.check_files,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn validate_workflow_against_object_info(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
workflow: &Value,
|
||||
object_info: &Value,
|
||||
check_files: bool,
|
||||
) -> Value {
|
||||
let nodes = workflow
|
||||
.get("nodes")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let links = workflow
|
||||
.get("links")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let linked_node_ids = workflow_linked_node_ids(&links);
|
||||
let mut missing_node_types = Vec::<Value>::new();
|
||||
let mut empty_model_fields = Vec::<Value>::new();
|
||||
let mut missing_model_choices = Vec::<Value>::new();
|
||||
let mut unknown_model_fields = Vec::<Value>::new();
|
||||
let mut input_files = Vec::<Value>::new();
|
||||
let mut node_type_counts = HashMap::<String, usize>::new();
|
||||
|
||||
for node in &nodes {
|
||||
let node_id = node.get("id").and_then(Value::as_i64).unwrap_or_default();
|
||||
let node_type = node
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if node_type.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*node_type_counts.entry(node_type.clone()).or_default() += 1;
|
||||
let connected = linked_node_ids.contains(&node_id);
|
||||
let Some(node_def) = object_info.get(&node_type) else {
|
||||
missing_node_types.push(json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"connected": connected,
|
||||
"severity": if connected { "error" } else { "warning" },
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
let widgets = node
|
||||
.get("widgets_values")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let combo_fields = combo_fields_from_node(object_info, &node_type);
|
||||
let widget_inputs = node
|
||||
.get("inputs")
|
||||
.and_then(Value::as_array)
|
||||
.map(|inputs| workflow_widget_inputs(inputs))
|
||||
.unwrap_or_default();
|
||||
let mut widget_index = 0usize;
|
||||
for input in widget_inputs {
|
||||
let field = input
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let input_type = input
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let Some(widget_value) = widgets.get(widget_index) else {
|
||||
break;
|
||||
};
|
||||
widget_index += 1;
|
||||
if !is_modelish_field(&field, &input_type, widget_value) {
|
||||
continue;
|
||||
}
|
||||
let Some(selected) = workflow_widget_string(widget_value) else {
|
||||
continue;
|
||||
};
|
||||
if selected.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if is_input_file_field(&field, &input_type, &selected) {
|
||||
if check_files {
|
||||
input_files.push(
|
||||
validate_workflow_input_file(state, caller, node_id, &node_type, &selected)
|
||||
.await,
|
||||
);
|
||||
} else {
|
||||
input_files.push(json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"filename": selected,
|
||||
"checked": false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let choices = combo_fields
|
||||
.iter()
|
||||
.find(|(name, _)| name == &field)
|
||||
.map(|(_, choices)| choices.clone())
|
||||
.or_else(|| {
|
||||
combo_fields
|
||||
.iter()
|
||||
.find(|(name, _)| field_names_are_equivalent(name, &field))
|
||||
.map(|(_, choices)| choices.clone())
|
||||
});
|
||||
match choices {
|
||||
Some(choices) if choices.is_empty() => empty_model_fields.push(json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"field": field,
|
||||
"selected": selected,
|
||||
"severity": if connected { "error" } else { "warning" },
|
||||
"message": "后端这个模型/LoRA字段返回空列表,当前选择无法确认可用。",
|
||||
})),
|
||||
Some(choices) if !choices.iter().any(|choice| choice == &selected) => {
|
||||
let near = choices
|
||||
.iter()
|
||||
.filter(|choice| {
|
||||
let a = choice.to_ascii_lowercase();
|
||||
let b = selected.to_ascii_lowercase();
|
||||
a.contains(&b) || b.contains(&a)
|
||||
})
|
||||
.take(8)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
missing_model_choices.push(json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"field": field,
|
||||
"selected": selected,
|
||||
"near": near,
|
||||
"availableCount": choices.len(),
|
||||
"severity": if connected { "error" } else { "warning" },
|
||||
}));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if node_def.get("input").is_some() {
|
||||
unknown_model_fields.push(json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"field": field,
|
||||
"selected": selected,
|
||||
"severity": "info",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let errors = missing_node_types
|
||||
.iter()
|
||||
.chain(empty_model_fields.iter())
|
||||
.chain(missing_model_choices.iter())
|
||||
.filter(|item| {
|
||||
item.get("severity")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|severity| severity == "error")
|
||||
})
|
||||
.count();
|
||||
let warnings = missing_node_types
|
||||
.iter()
|
||||
.chain(empty_model_fields.iter())
|
||||
.chain(missing_model_choices.iter())
|
||||
.filter(|item| {
|
||||
item.get("severity")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|severity| severity == "warning")
|
||||
})
|
||||
.count();
|
||||
|
||||
json!({
|
||||
"success": true,
|
||||
"compatible": errors == 0,
|
||||
"summary": {
|
||||
"nodes": nodes.len(),
|
||||
"links": links.len(),
|
||||
"nodeTypes": node_type_counts.len(),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
},
|
||||
"missingNodeTypes": missing_node_types,
|
||||
"emptyModelFields": empty_model_fields,
|
||||
"missingModelChoices": missing_model_choices,
|
||||
"unknownModelFields": unknown_model_fields,
|
||||
"inputFiles": input_files,
|
||||
"hints": workflow_validation_hints(errors, warnings),
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_workflow_input_file(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
node_id: i64,
|
||||
node_type: &str,
|
||||
filename: &str,
|
||||
) -> Value {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("filename".to_string(), filename.to_string());
|
||||
params.insert("type".to_string(), "input".to_string());
|
||||
let path = format!("/view{}", build_query_string(¶ms));
|
||||
let result = native_workflow_request(state, Method::GET, &path, None)
|
||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||
.send()
|
||||
.await;
|
||||
return match result {
|
||||
Ok(response) => json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"filename": filename,
|
||||
"checked": true,
|
||||
"reachable": response.status().is_success(),
|
||||
"status": response.status().as_u16(),
|
||||
"contentType": response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
}),
|
||||
Err(err) => json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"filename": filename,
|
||||
"checked": true,
|
||||
"reachable": false,
|
||||
"error": err.to_string(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
let user_id = caller_user_id(caller);
|
||||
let path = user_home(user_id)
|
||||
.join("userdata")
|
||||
.join("input")
|
||||
.join(safe_view_path(filename));
|
||||
json!({
|
||||
"nodeId": node_id,
|
||||
"nodeType": node_type,
|
||||
"filename": filename,
|
||||
"checked": true,
|
||||
"reachable": tokio::fs::metadata(&path).await.is_ok(),
|
||||
"localPath": path,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
use crate::workflow::executor::{WorkflowProgressCallback, WorkflowProgressEvent};
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocketUpgrade},
|
||||
Query, State,
|
||||
},
|
||||
http::HeaderMap,
|
||||
response::Response,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{mpsc, Arc},
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_tungstenite::{connect_async, tungstenite};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
|
||||
use super::workflow_node_label;
|
||||
use super::{
|
||||
build_query_string, native_workflow_base, native_workflow_enabled, node_output_for_comfy,
|
||||
timestamp_ms,
|
||||
};
|
||||
use super::auth::auth_caller;
|
||||
|
||||
static WORKFLOW_WS_SESSIONS: Lazy<parking_lot::Mutex<HashMap<String, broadcast::Sender<Value>>>> =
|
||||
Lazy::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
fn workflow_session_sender(session_id: &str) -> broadcast::Sender<Value> {
|
||||
let mut sessions = WORKFLOW_WS_SESSIONS.lock();
|
||||
if let Some(sender) = sessions.get(session_id) {
|
||||
return sender.clone();
|
||||
}
|
||||
let (sender, _) = broadcast::channel(128);
|
||||
sessions.insert(session_id.to_string(), sender.clone());
|
||||
sender
|
||||
}
|
||||
|
||||
fn publish_workflow_ws_event(session_id: Option<&str>, event_type: &str, data: Value) {
|
||||
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let sender = workflow_session_sender(session_id);
|
||||
let _ = sender.send(json!({
|
||||
"type": event_type,
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn workflow_progress_callback_with_run_sender(
|
||||
session_id: Option<String>,
|
||||
run_sender: Option<mpsc::SyncSender<String>>,
|
||||
) -> Option<WorkflowProgressCallback> {
|
||||
let session_id = session_id.map(|value| value.trim().to_string())?;
|
||||
if session_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let run_sender = Arc::new(parking_lot::Mutex::new(run_sender));
|
||||
Some(Arc::new(move |event| match event {
|
||||
WorkflowProgressEvent::Started { prompt_id, total } => {
|
||||
if let Some(sender) = run_sender.lock().take() {
|
||||
let _ = sender.send(prompt_id.clone());
|
||||
}
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"status",
|
||||
json!({ "status": { "exec_info": { "queue_remaining": 1 } } }),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"execution_start",
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"timestamp": timestamp_ms(),
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"progress",
|
||||
json!({ "value": 0, "max": total.max(1) }),
|
||||
);
|
||||
}
|
||||
WorkflowProgressEvent::NodeStarted {
|
||||
prompt_id,
|
||||
node_id,
|
||||
node_type,
|
||||
index,
|
||||
total,
|
||||
} => {
|
||||
let label = workflow_node_label(&node_type, &node_id);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"executing",
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"node": node_id,
|
||||
"node_type": node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"progress",
|
||||
json!({ "value": index, "max": total.max(1) }),
|
||||
);
|
||||
}
|
||||
WorkflowProgressEvent::NodeCompleted {
|
||||
prompt_id,
|
||||
node_id,
|
||||
node_type,
|
||||
index,
|
||||
total,
|
||||
output,
|
||||
} => {
|
||||
let label = workflow_node_label(&node_type, &node_id);
|
||||
let output = node_output_for_comfy(&output).unwrap_or(output);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"executed",
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"node": node_id,
|
||||
"node_type": node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
"output": output,
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"progress",
|
||||
json!({ "value": index, "max": total.max(1) }),
|
||||
);
|
||||
}
|
||||
WorkflowProgressEvent::NodeProgress {
|
||||
prompt_id,
|
||||
node_id,
|
||||
node_type,
|
||||
value,
|
||||
max,
|
||||
message,
|
||||
} => {
|
||||
let label = workflow_node_label(&node_type, &node_id);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"executing",
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"node": node_id,
|
||||
"node_type": node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
"message": message,
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"progress",
|
||||
json!({ "value": value, "max": max.max(1), "message": message }),
|
||||
);
|
||||
}
|
||||
WorkflowProgressEvent::Finished {
|
||||
prompt_id,
|
||||
success,
|
||||
error,
|
||||
} => {
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"executing",
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"node": Value::Null,
|
||||
"display_node": Value::Null,
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
"status",
|
||||
json!({ "status": { "exec_info": { "queue_remaining": 0 } } }),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(&session_id),
|
||||
if success {
|
||||
"execution_success"
|
||||
} else {
|
||||
"execution_error"
|
||||
},
|
||||
if success {
|
||||
json!({ "prompt_id": prompt_id, "timestamp": timestamp_ms() })
|
||||
} else {
|
||||
json!({
|
||||
"prompt_id": prompt_id,
|
||||
"timestamp": timestamp_ms(),
|
||||
"node_id": "workflow",
|
||||
"node_type": "Workflow",
|
||||
"exception_message": error.unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"traceback": [],
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// APPEND_MARKER_PUBLISH_RUN_EVENTS
|
||||
|
||||
pub(super) fn publish_run_events(
|
||||
session_id: Option<&str>,
|
||||
run: &crate::workflow::models::WorkflowRunSummary,
|
||||
outputs: &Value,
|
||||
) {
|
||||
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
publish_workflow_ws_event(
|
||||
Some(session_id),
|
||||
"execution_start",
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": timestamp_ms(),
|
||||
}),
|
||||
);
|
||||
|
||||
for node in &run.node_runs {
|
||||
let label = workflow_node_label(&node.node_type, &node.node_id);
|
||||
publish_workflow_ws_event(
|
||||
Some(session_id),
|
||||
"executing",
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"node": node.node_id,
|
||||
"node_type": node.node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
}),
|
||||
);
|
||||
if let Some(output) = outputs.get(&node.node_id) {
|
||||
publish_workflow_ws_event(
|
||||
Some(session_id),
|
||||
"executed",
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"node": node.node_id,
|
||||
"node_type": node.node_type,
|
||||
"display_node": label,
|
||||
"label": label,
|
||||
"output": output,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
publish_workflow_ws_event(
|
||||
Some(session_id),
|
||||
"executing",
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"node": Value::Null,
|
||||
"display_node": Value::Null,
|
||||
}),
|
||||
);
|
||||
publish_workflow_ws_event(
|
||||
Some(session_id),
|
||||
if run.status == "failed" {
|
||||
"execution_error"
|
||||
} else {
|
||||
"execution_success"
|
||||
},
|
||||
if run.status == "failed" {
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": timestamp_ms(),
|
||||
"node_id": run.node_runs.last().map(|node| node.node_id.as_str()).unwrap_or("workflow"),
|
||||
"node_type": run.node_runs.last().map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||
"exception_type": "BoosAPIWorkflowError",
|
||||
"traceback": [],
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"prompt_id": run.public_id,
|
||||
"timestamp": timestamp_ms(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// APPEND_MARKER_WS_HANDLERS
|
||||
|
||||
pub(super) async fn workflow_ws(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> AppResult<Response> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
let sid = query
|
||||
.get("clientId")
|
||||
.or_else(|| query.get("client_id"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "boosapi-workflow".to_string());
|
||||
if native_workflow_enabled(&state) && !state.config.comfyui.uses_runninghub_backend() {
|
||||
return Ok(
|
||||
ws.on_upgrade(move |socket| native_workflow_ws_bridge(state, socket, query, sid))
|
||||
);
|
||||
}
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
use tokio::time::{interval, Duration};
|
||||
let mut socket = socket;
|
||||
let mut receiver = workflow_session_sender(&sid).subscribe();
|
||||
let mut ticker = interval(Duration::from_secs(20));
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "status",
|
||||
"data": {
|
||||
"status": { "exec_info": { "queue_remaining": 0 } },
|
||||
"sid": sid
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = receiver.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if socket.send(Message::Text(event.to_string().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = ticker.tick() => {
|
||||
if socket.send(Message::Text(json!({"type":"status","data":{"status":{"exec_info":{"queue_remaining":0}},"sid":sid}}).to_string().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
next = socket.recv() => {
|
||||
match next {
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// APPEND_MARKER_BRIDGE
|
||||
|
||||
async fn native_workflow_ws_bridge(
|
||||
state: AppState,
|
||||
mut client_socket: axum::extract::ws::WebSocket,
|
||||
mut query: HashMap<String, String>,
|
||||
sid: String,
|
||||
) {
|
||||
let mut ws_url = native_workflow_base(&state)
|
||||
.replacen("https://", "wss://", 1)
|
||||
.replacen("http://", "ws://", 1);
|
||||
query.entry("clientId".to_string()).or_insert(sid.clone());
|
||||
if let Some(api_key) = state
|
||||
.config
|
||||
.comfyui
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
query
|
||||
.entry("token".to_string())
|
||||
.or_insert_with(|| api_key.to_string());
|
||||
}
|
||||
ws_url.push_str("/ws");
|
||||
ws_url.push_str(&build_query_string(&query));
|
||||
|
||||
let request = match ws_url.as_str().into_client_request() {
|
||||
Ok(mut request) => {
|
||||
if let Some(api_key) = state
|
||||
.config
|
||||
.comfyui
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Ok(value) = tungstenite::http::HeaderValue::from_str(api_key) {
|
||||
request.headers_mut().insert("X-API-Key", value);
|
||||
}
|
||||
if let Ok(value) =
|
||||
tungstenite::http::HeaderValue::from_str(&format!("Bearer {api_key}"))
|
||||
{
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(tungstenite::http::header::AUTHORIZATION, value);
|
||||
}
|
||||
}
|
||||
request
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = client_socket
|
||||
.send(Message::Text(
|
||||
json!({"type":"execution_error","data":{"exception_message":format!("invalid ComfyUI websocket URL: {err}")}})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let upstream_target = ws_url
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(ws_url.as_str())
|
||||
.to_string();
|
||||
let (upstream_socket, _) = match connect_async(request).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(target = %upstream_target, error = %err, "ComfyUI native websocket connect failed");
|
||||
let _ = client_socket
|
||||
.send(Message::Text(
|
||||
json!({"type":"execution_error","data":{"exception_message":format!("ComfyUI websocket connect failed: {err}")}})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
from_upstream = upstream_read.next() => {
|
||||
match from_upstream {
|
||||
Some(Ok(message)) => {
|
||||
let outgoing = match message {
|
||||
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
|
||||
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
|
||||
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
|
||||
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
|
||||
tungstenite::Message::Close(frame) => {
|
||||
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
|
||||
code: frame.code.into(),
|
||||
reason: frame.reason.to_string().into(),
|
||||
}))).await;
|
||||
break;
|
||||
}
|
||||
tungstenite::Message::Frame(_) => continue,
|
||||
};
|
||||
if client_socket.send(outgoing).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => break,
|
||||
}
|
||||
}
|
||||
from_client = client_socket.recv() => {
|
||||
match from_client {
|
||||
Some(Ok(message)) => {
|
||||
let outgoing = match message {
|
||||
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
|
||||
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
|
||||
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
|
||||
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
|
||||
Message::Close(frame) => {
|
||||
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
|
||||
code: frame.code.into(),
|
||||
reason: frame.reason.to_string().into(),
|
||||
}))).await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if upstream_write.send(outgoing).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Pending-approval registry for the Agent Workbench.
|
||||
//!
|
||||
//! When a session runs in a mode that requires approval for a risky tool, the
|
||||
//! permission callback registers a pending approval, emits an `approval_request`
|
||||
//! SSE event, and waits for either the user's decision or a timeout. The
|
||||
//! `POST /api/agent/approve` endpoint resolves the pending entry.
|
||||
//!
|
||||
//! ## Single source of truth
|
||||
//!
|
||||
//! Each entry carries an [`AtomicU8`] state machine (`Pending → Approved /
|
||||
//! Denied / TimedOut`) plus a [`Notify`]. The decision is made by exactly one
|
||||
//! `compare_exchange` from `Pending`: whichever side wins (the approver or the
|
||||
//! timeout) settles the state; everyone else observes a losing CAS and no-ops.
|
||||
//!
|
||||
//! This closes the race that a bare `oneshot` channel had, where the HTTP
|
||||
//! response (derived from `Sender::send`'s bool) and the loop's allow/deny
|
||||
//! decision (derived from which `select`/`timeout` arm fired) were two
|
||||
//! independent signals that could disagree. Now both read the same atomic:
|
||||
//! `resolve` reports back whether *its* CAS won, and the callback decides purely
|
||||
//! from the settled state — so the frontend's `ok` and the executed decision can
|
||||
//! never contradict each other.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Settled decision states. `Pending` is the only non-terminal value.
|
||||
const PENDING: u8 = 0;
|
||||
const APPROVED: u8 = 1;
|
||||
const DENIED: u8 = 2;
|
||||
const TIMED_OUT: u8 = 3;
|
||||
|
||||
/// User's decision on a pending tool approval.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalOutcome {
|
||||
Approve,
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// Final, settled state of an approval — the single source of truth read by the
|
||||
/// permission callback to decide allow vs deny.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettledState {
|
||||
Approved,
|
||||
Denied,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Shared approval entry: an atomic state machine plus a wakeup primitive.
|
||||
#[derive(Debug)]
|
||||
pub struct ApprovalEntry {
|
||||
state: AtomicU8,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl ApprovalEntry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicU8::new(PENDING),
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to settle this entry to a terminal state from `Pending`.
|
||||
/// Returns `true` iff this caller's CAS won the race.
|
||||
fn try_settle(&self, target: u8) -> bool {
|
||||
self.state
|
||||
.compare_exchange(PENDING, target, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn settled(&self) -> Option<SettledState> {
|
||||
match self.state.load(Ordering::SeqCst) {
|
||||
APPROVED => Some(SettledState::Approved),
|
||||
DENIED => Some(SettledState::Denied),
|
||||
TIMED_OUT => Some(SettledState::TimedOut),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REGISTRY: Lazy<Mutex<HashMap<String, Arc<ApprovalEntry>>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// RAII guard that removes its entry from the registry when dropped, so a
|
||||
/// pending approval is cleaned up whether the callback finishes normally or its
|
||||
/// future is dropped (session aborted/disconnected). No leak on any path.
|
||||
pub struct PendingGuard {
|
||||
request_id: String,
|
||||
}
|
||||
|
||||
impl Drop for PendingGuard {
|
||||
fn drop(&mut self) {
|
||||
REGISTRY.lock().unwrap().remove(&self.request_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new pending approval. Returns the shared entry (await its
|
||||
/// [`wait_settled`](ApprovalEntry)/notify and read its settled state) plus a
|
||||
/// [`PendingGuard`] that the caller must hold for the lifetime of the wait; when
|
||||
/// the guard drops, the registry entry is removed.
|
||||
pub fn register(request_id: &str) -> (Arc<ApprovalEntry>, PendingGuard) {
|
||||
let entry = Arc::new(ApprovalEntry::new());
|
||||
REGISTRY
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(request_id.to_string(), entry.clone());
|
||||
(
|
||||
entry,
|
||||
PendingGuard {
|
||||
request_id: request_id.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait until the entry is settled or `timeout` elapses, then return the settled
|
||||
/// state. On timeout this claims `TimedOut` (if still pending) so the result is
|
||||
/// always a terminal state read from the single atomic.
|
||||
pub async fn wait_for_decision(
|
||||
entry: &ApprovalEntry,
|
||||
timeout: std::time::Duration,
|
||||
) -> SettledState {
|
||||
// Create the notified future *before* checking state to avoid a lost wakeup.
|
||||
let notified = entry.notify.notified();
|
||||
|
||||
// Fast path: already settled by a resolve that raced ahead of us.
|
||||
if let Some(s) = entry.settled() {
|
||||
return s;
|
||||
}
|
||||
|
||||
if tokio::time::timeout(timeout, notified).await.is_err() {
|
||||
// Timer fired first — claim TimedOut if nobody settled in the meantime.
|
||||
entry.try_settle(TIMED_OUT);
|
||||
}
|
||||
|
||||
// Single source of truth: whatever the atomic settled to. If a wakeup was
|
||||
// lost but the state is set, we still read the correct decision; if somehow
|
||||
// still pending (shouldn't happen — we just CAS'd), treat as timed out.
|
||||
entry.settled().unwrap_or(SettledState::TimedOut)
|
||||
}
|
||||
|
||||
/// Resolve a pending approval with the user's decision.
|
||||
///
|
||||
/// Returns `true` iff this call's `compare_exchange` won — i.e. it is the call
|
||||
/// that actually settled the decision. Returns `false` truthfully when the id is
|
||||
/// unknown (already cleaned up) or when the timeout (or another resolve) already
|
||||
/// claimed the entry, so the frontend is never told its vote took effect when it
|
||||
/// did not.
|
||||
pub fn resolve(request_id: &str, outcome: ApprovalOutcome) -> bool {
|
||||
let entry = REGISTRY.lock().unwrap().get(request_id).cloned();
|
||||
let Some(entry) = entry else {
|
||||
return false;
|
||||
};
|
||||
let target = match outcome {
|
||||
ApprovalOutcome::Approve => APPROVED,
|
||||
ApprovalOutcome::Deny => DENIED,
|
||||
};
|
||||
if entry.try_settle(target) {
|
||||
entry.notify.notify_one();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_before_wait_is_observed() {
|
||||
let (entry, _guard) = register("r1");
|
||||
assert!(resolve("r1", ApprovalOutcome::Approve));
|
||||
let s = wait_for_decision(&entry, Duration::from_secs(5)).await;
|
||||
assert_eq!(s, SettledState::Approved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_is_observed() {
|
||||
let (entry, _guard) = register("r2");
|
||||
assert!(resolve("r2", ApprovalOutcome::Deny));
|
||||
assert_eq!(
|
||||
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||
SettledState::Denied
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_settles_timed_out_and_later_resolve_loses() {
|
||||
let (entry, _guard) = register("r3");
|
||||
// Very short timeout → times out with no resolve.
|
||||
let s = wait_for_decision(&entry, Duration::from_millis(20)).await;
|
||||
assert_eq!(s, SettledState::TimedOut);
|
||||
// A late approve must report false: the CAS already went to TimedOut.
|
||||
assert!(!resolve("r3", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_cas_wins_second_resolve_loses() {
|
||||
let (_entry, _guard) = register("r4");
|
||||
assert!(resolve("r4", ApprovalOutcome::Approve));
|
||||
assert!(!resolve("r4", ApprovalOutcome::Deny));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_unknown_id_is_false() {
|
||||
assert!(!resolve("nope", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_drop_removes_entry() {
|
||||
{
|
||||
let (_entry, _guard) = register("r5");
|
||||
} // guard dropped here
|
||||
// Entry gone → resolve can't find it.
|
||||
assert!(!resolve("r5", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resolve_and_timeout_never_both_win() {
|
||||
// Stress the boundary: race a resolve against a near-zero timeout many
|
||||
// times and assert the frontend-ok and the settled decision always agree.
|
||||
for i in 0..2000 {
|
||||
let id = format!("race-{i}");
|
||||
let (entry, _guard) = register(&id);
|
||||
let id2 = id.clone();
|
||||
let resolver = tokio::spawn(async move { resolve(&id2, ApprovalOutcome::Approve) });
|
||||
let decided = wait_for_decision(&entry, Duration::from_nanos(1)).await;
|
||||
let won = resolver.await.unwrap();
|
||||
// The single-atomic invariant: ok==true <=> decision==Approved.
|
||||
match (won, decided) {
|
||||
(true, SettledState::Approved) => {}
|
||||
(false, SettledState::TimedOut) => {}
|
||||
other => panic!("inconsistent outcome: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! OS-level sandboxed Bash tool for the Agent Workbench.
|
||||
//!
|
||||
//! The open-agent-sdk's built-in `Bash` tool runs commands as a plain
|
||||
//! subprocess with only `current_dir` set — no OS boundary. On a server that
|
||||
//! means an agent's shell can `cat /etc/shadow`, `cd /`, write anywhere the
|
||||
//! process uid can, and reach the network. This tool replaces it (same name +
|
||||
//! schema, registered via `custom_tools` so it overrides the built-in) and
|
||||
//! confines the command with [bubblewrap](https://github.com/containers/bubblewrap):
|
||||
//!
|
||||
//! - **Default / AcceptEdits** (review modes): the command runs inside a
|
||||
//! bwrap namespace with the system mounted read-only, the session sandbox
|
||||
//! directory bind-mounted read-write at its real path, `/etc`, `/root`,
|
||||
//! `/home` and every other host path *absent*, and the network unshared.
|
||||
//! Filesystem reads/writes outside the session dir and all network egress are
|
||||
//! therefore impossible at the kernel level, not merely discouraged.
|
||||
//! - **FullAccess** (explicitly dangerous): the command runs unconfined, the
|
||||
//! same behaviour the bare SDK had, but every command is audit-logged with a
|
||||
//! loud warning.
|
||||
//!
|
||||
//! **Fail-closed:** in a review mode, if `bwrap` is unavailable the command is
|
||||
//! refused rather than silently run unconfined — we never fabricate a boundary.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::services::agent_workbench::permission::PermissionMode;
|
||||
use open_agent_sdk::types::{Tool, ToolError, ToolInputSchema, ToolResult, ToolUseContext};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
|
||||
const MAX_TIMEOUT_MS: u64 = 600_000;
|
||||
const MAX_OUTPUT_SIZE: usize = 100_000;
|
||||
|
||||
/// Read-only host paths exposed inside the sandbox so common tooling works.
|
||||
/// Deliberately excludes `/etc`, `/root`, `/home`, `/var`, `/proc`-host, etc.
|
||||
const RO_SYSTEM_PATHS: &[&str] = &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"];
|
||||
|
||||
/// A Bash tool that confines commands to the session sandbox via bubblewrap.
|
||||
pub struct SandboxedBashTool {
|
||||
/// Canonical session sandbox directory (bind-mounted rw inside bwrap).
|
||||
session_dir: PathBuf,
|
||||
/// Session permission mode — decides confined vs unconfined.
|
||||
mode: PermissionMode,
|
||||
/// Resolved `bwrap` binary path, if present on this host.
|
||||
bwrap: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl SandboxedBashTool {
|
||||
pub fn new(session_dir: PathBuf, mode: PermissionMode) -> Self {
|
||||
Self {
|
||||
session_dir,
|
||||
mode,
|
||||
bwrap: find_bwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this mode confines bash (everything except FullAccess).
|
||||
fn is_confined(&self) -> bool {
|
||||
!matches!(self.mode, PermissionMode::FullAccess)
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the `bwrap` binary (PATH + well-known locations).
|
||||
fn find_bwrap() -> Option<PathBuf> {
|
||||
for cand in ["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"] {
|
||||
let p = Path::new(cand);
|
||||
if p.is_file() {
|
||||
return Some(p.to_path_buf());
|
||||
}
|
||||
}
|
||||
std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.map(|d| d.join("bwrap"))
|
||||
.find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the bwrap argument vector that confines `command` to `session_dir`.
|
||||
///
|
||||
/// Pure and unit-testable. The session directory is bound read-write at its own
|
||||
/// real absolute path and becomes the working directory; the rest of the
|
||||
/// filesystem is either read-only (system paths) or absent. `--unshare-all`
|
||||
/// (which includes the network namespace) plus `--die-with-parent` and
|
||||
/// `--new-session` complete the isolation.
|
||||
pub fn build_bwrap_args(session_dir: &Path, command: &str) -> Vec<String> {
|
||||
let sd = session_dir.to_string_lossy().to_string();
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
|
||||
for p in RO_SYSTEM_PATHS {
|
||||
// `--ro-bind-try` tolerates paths that don't exist on this host.
|
||||
args.push("--ro-bind-try".into());
|
||||
args.push((*p).into());
|
||||
args.push((*p).into());
|
||||
}
|
||||
// Minimal virtual filesystems.
|
||||
args.push("--dev".into());
|
||||
args.push("/dev".into());
|
||||
args.push("--proc".into());
|
||||
args.push("/proc".into());
|
||||
args.push("--tmpfs".into());
|
||||
args.push("/tmp".into());
|
||||
// The session sandbox, bound rw at its real path, then made the cwd.
|
||||
args.push("--bind".into());
|
||||
args.push(sd.clone());
|
||||
args.push(sd.clone());
|
||||
args.push("--chdir".into());
|
||||
args.push(sd);
|
||||
// Full namespace isolation incl. network; tear down with the parent.
|
||||
args.push("--unshare-all".into());
|
||||
args.push("--die-with-parent".into());
|
||||
args.push("--new-session".into());
|
||||
// Run the command under a shell inside the sandbox.
|
||||
args.push("/bin/bash".into());
|
||||
args.push("-c".into());
|
||||
args.push(command.into());
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SandboxedBashTool {
|
||||
fn name(&self) -> &str {
|
||||
"Bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Executes a bash command inside an OS-level sandbox confined to the \
|
||||
session working directory (read-only system, no network) unless the \
|
||||
session is in full-access mode. Use for shell operations."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> ToolInputSchema {
|
||||
ToolInputSchema {
|
||||
schema_type: "object".to_string(),
|
||||
properties: HashMap::from([
|
||||
(
|
||||
"command".to_string(),
|
||||
json!({ "type": "string", "description": "The command to execute" }),
|
||||
),
|
||||
(
|
||||
"timeout".to_string(),
|
||||
json!({ "type": "number", "description": "Optional timeout in milliseconds (max 600000)" }),
|
||||
),
|
||||
(
|
||||
"description".to_string(),
|
||||
json!({ "type": "string", "description": "Clear description of what this command does" }),
|
||||
),
|
||||
]),
|
||||
required: vec!["command".to_string()],
|
||||
additional_properties: Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(&self, input: Value, context: &ToolUseContext) -> Result<ToolResult, ToolError> {
|
||||
let command = input
|
||||
.get("command")
|
||||
.and_then(|c| c.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidInput("Missing 'command' field".to_string()))?;
|
||||
|
||||
let timeout_ms = input
|
||||
.get("timeout")
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.min(MAX_TIMEOUT_MS);
|
||||
|
||||
// Build the runner per mode.
|
||||
let (program, args): (String, Vec<String>) = if self.is_confined() {
|
||||
let Some(bwrap) = &self.bwrap else {
|
||||
// Fail closed: never downgrade a review-mode session to an
|
||||
// unconfined shell just because the sandbox tool is missing.
|
||||
return Ok(ToolResult::error(
|
||||
"Bash is disabled: the OS sandbox (bwrap) is not available on \
|
||||
this host, and this session runs in a review mode that requires \
|
||||
confinement. Re-run in full-access mode (explicitly unsafe) or \
|
||||
install bubblewrap."
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
(
|
||||
bwrap.to_string_lossy().to_string(),
|
||||
build_bwrap_args(&self.session_dir, command),
|
||||
)
|
||||
} else {
|
||||
// FullAccess: unconfined, but audit every command loudly.
|
||||
tracing::warn!(
|
||||
target: "boosapi::agent::bash",
|
||||
mode = "full_access",
|
||||
command = command,
|
||||
"UNCONFINED agent bash command (full-access mode)"
|
||||
);
|
||||
(
|
||||
"/bin/bash".to_string(),
|
||||
vec!["-c".to_string(), command.to_string()],
|
||||
)
|
||||
};
|
||||
|
||||
// Unconfined mode honours the SDK's working dir; confined mode's cwd is
|
||||
// set inside bwrap via --chdir, so the outer cwd is irrelevant.
|
||||
let outer_cwd = if self.is_confined() {
|
||||
self.session_dir.clone()
|
||||
} else {
|
||||
PathBuf::from(&context.working_dir)
|
||||
};
|
||||
|
||||
let fut = run_capture(&program, &args, &outer_cwd);
|
||||
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), fut).await {
|
||||
Ok(Ok((stdout, stderr, code))) => Ok(format_result(stdout, stderr, code)),
|
||||
Ok(Err(e)) => Ok(ToolResult::error(format!("Command failed: {e}"))),
|
||||
Err(_) => Ok(ToolResult::error(format!(
|
||||
"Command timed out after {timeout_ms}ms"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_capture(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
) -> Result<(String, String, i32), std::io::Error> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
Ok((
|
||||
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
output.status.code().unwrap_or(-1),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_result(stdout: String, stderr: String, exit_code: i32) -> ToolResult {
|
||||
let mut result = String::new();
|
||||
if !stdout.is_empty() {
|
||||
result.push_str(&stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str("STDERR:\n");
|
||||
result.push_str(&stderr);
|
||||
}
|
||||
if result.len() > MAX_OUTPUT_SIZE {
|
||||
result.truncate(MAX_OUTPUT_SIZE);
|
||||
result.push_str("\n... (output truncated)");
|
||||
}
|
||||
if exit_code != 0 {
|
||||
result.push_str(&format!("\n\nExit code: {exit_code}"));
|
||||
}
|
||||
if result.is_empty() {
|
||||
result = "(no output)".to_string();
|
||||
}
|
||||
if exit_code != 0 {
|
||||
ToolResult::error(result)
|
||||
} else {
|
||||
ToolResult::text(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn args_bind_session_dir_and_isolate() {
|
||||
let sd = Path::new("/srv/data/agent_sessions/abc");
|
||||
let args = build_bwrap_args(sd, "echo hi");
|
||||
let joined = args.join(" ");
|
||||
// Session dir is bound rw and made the cwd.
|
||||
assert!(joined.contains("--bind /srv/data/agent_sessions/abc /srv/data/agent_sessions/abc"));
|
||||
assert!(joined.contains("--chdir /srv/data/agent_sessions/abc"));
|
||||
// Network + everything else unshared, parent-bound.
|
||||
assert!(args.iter().any(|a| a == "--unshare-all"));
|
||||
assert!(args.iter().any(|a| a == "--die-with-parent"));
|
||||
// /etc is never bound.
|
||||
assert!(!joined.contains("/etc"));
|
||||
// System paths are read-only.
|
||||
assert!(joined.contains("--ro-bind-try /usr /usr"));
|
||||
// The command is the last argument, run under bash -c.
|
||||
assert_eq!(args.last().unwrap(), "echo hi");
|
||||
let n = args.len();
|
||||
assert_eq!(args[n - 3], "/bin/bash");
|
||||
assert_eq!(args[n - 2], "-c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confinement_follows_mode() {
|
||||
let sd = PathBuf::from("/tmp/x");
|
||||
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::Default).is_confined());
|
||||
assert!(SandboxedBashTool::new(sd.clone(), PermissionMode::AcceptEdits).is_confined());
|
||||
assert!(!SandboxedBashTool::new(sd, PermissionMode::FullAccess).is_confined());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_overrides_builtin_bash() {
|
||||
let t = SandboxedBashTool::new(PathBuf::from("/tmp/x"), PermissionMode::Default);
|
||||
assert_eq!(t.name(), "Bash");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Agent Workbench: a general-purpose, server-side AI agent exposed over SSE.
|
||||
//!
|
||||
//! The agent loop is provided by the `open-agent-sdk` crate. This module owns
|
||||
//! the BoosAPI-specific concerns layered on top of it:
|
||||
//!
|
||||
//! - [`permission`]: tool risk classification + the per-mode decision matrix.
|
||||
//! - [`sandbox`]: per-session isolated working directory + path validation.
|
||||
//! - [`bash_sandbox`]: OS-level (bubblewrap) confinement for the Bash tool.
|
||||
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
|
||||
//! - [`session_store`]: persistence of per-session token/cost accounting.
|
||||
//!
|
||||
//! The HTTP surface (SSE chat, approval callback, status) lives in
|
||||
//! `crate::routes::agent`.
|
||||
|
||||
pub mod approval;
|
||||
pub mod bash_sandbox;
|
||||
pub mod permission;
|
||||
pub mod sandbox;
|
||||
pub mod session_store;
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Tool risk classification and permission decision matrix for the Agent Workbench.
|
||||
//!
|
||||
//! Ported and adapted from the AQBot agent crate. Deliberately SDK-free so it
|
||||
//! can be unit-tested in isolation; the route layer maps [`PermissionAction`]
|
||||
//! onto the SDK's `PermissionDecision`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Risk level of a tool, inferred from its name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RiskLevel {
|
||||
/// Reads only; never mutates the filesystem or runs commands.
|
||||
ReadOnly,
|
||||
/// Mutates files (write/edit/create/delete/...).
|
||||
Write,
|
||||
/// Executes arbitrary commands (bash/shell/...).
|
||||
Execute,
|
||||
}
|
||||
|
||||
/// Permission mode for an agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionMode {
|
||||
/// Standard review: read auto-allowed, write/execute require approval.
|
||||
Default,
|
||||
/// Auto-accept edits: read + write auto-allowed, execute requires approval.
|
||||
AcceptEdits,
|
||||
/// Everything auto-allowed (no approvals).
|
||||
FullAccess,
|
||||
}
|
||||
|
||||
impl PermissionMode {
|
||||
/// Parse a mode from a wire string. Unknown values fall back to the safest
|
||||
/// mode ([`PermissionMode::Default`]).
|
||||
pub fn from_wire(s: &str) -> Self {
|
||||
match s {
|
||||
"accept_edits" | "acceptEdits" => Self::AcceptEdits,
|
||||
"full_access" | "fullAccess" => Self::FullAccess,
|
||||
_ => Self::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the permission system decides for a given tool invocation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionAction {
|
||||
/// Allow without prompting the user.
|
||||
AutoAllow,
|
||||
/// Pause and require explicit user approval before running.
|
||||
RequireApproval,
|
||||
}
|
||||
|
||||
/// Classify a tool's risk level from its name (case-insensitive).
|
||||
///
|
||||
/// Conservative by construction: anything that looks like it executes a command
|
||||
/// is [`RiskLevel::Execute`], anything that looks like it mutates state is
|
||||
/// [`RiskLevel::Write`], and everything else is [`RiskLevel::ReadOnly`].
|
||||
pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
|
||||
let n = tool_name.to_lowercase();
|
||||
|
||||
if matches!(n.as_str(), "bash" | "shell" | "run_command" | "execute")
|
||||
|| n.contains("exec")
|
||||
|| n.contains("bash")
|
||||
|| n.contains("shell")
|
||||
|| n.contains("command")
|
||||
|| n.contains("run")
|
||||
|| n.contains("terminal")
|
||||
|| n.contains("spawn")
|
||||
|| n.contains("process")
|
||||
{
|
||||
return RiskLevel::Execute;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
n.as_str(),
|
||||
"write" | "edit" | "create" | "delete" | "rename" | "patch" | "move" | "mkdir" | "remove"
|
||||
) || n.contains("write")
|
||||
|| n.contains("edit")
|
||||
|| n.contains("create")
|
||||
|| n.contains("delete")
|
||||
|| n.contains("patch")
|
||||
|| n.contains("apply")
|
||||
{
|
||||
return RiskLevel::Write;
|
||||
}
|
||||
|
||||
RiskLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Decide what to do for a tool invocation given the session mode and the tool's
|
||||
/// risk level.
|
||||
pub fn decide_permission(mode: PermissionMode, risk: RiskLevel) -> PermissionAction {
|
||||
match (mode, risk) {
|
||||
(PermissionMode::Default, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::Default, _) => PermissionAction::RequireApproval,
|
||||
|
||||
(PermissionMode::AcceptEdits, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::AcceptEdits, RiskLevel::Write) => PermissionAction::AutoAllow,
|
||||
(PermissionMode::AcceptEdits, RiskLevel::Execute) => PermissionAction::RequireApproval,
|
||||
|
||||
(PermissionMode::FullAccess, _) => PermissionAction::AutoAllow,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_execute_tools() {
|
||||
for t in ["Bash", "bash", "shell", "run_command", "execute", "RunInTerminal"] {
|
||||
assert_eq!(classify_tool_risk(t), RiskLevel::Execute, "tool={t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_write_tools() {
|
||||
for t in ["Write", "Edit", "create_file", "delete_file", "patch", "ApplyPatch"] {
|
||||
assert_eq!(classify_tool_risk(t), RiskLevel::Write, "tool={t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_read_tools() {
|
||||
for t in ["Read", "Glob", "Grep", "WebFetch", "list_dir"] {
|
||||
assert_eq!(classify_tool_risk(t), RiskLevel::ReadOnly, "tool={t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mode_requires_approval_for_write_and_execute() {
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::Default, RiskLevel::ReadOnly),
|
||||
PermissionAction::AutoAllow
|
||||
);
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::Default, RiskLevel::Write),
|
||||
PermissionAction::RequireApproval
|
||||
);
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::Default, RiskLevel::Execute),
|
||||
PermissionAction::RequireApproval
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_edits_allows_write_but_not_execute() {
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Write),
|
||||
PermissionAction::AutoAllow
|
||||
);
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Execute),
|
||||
PermissionAction::RequireApproval
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_access_allows_everything() {
|
||||
for r in [RiskLevel::ReadOnly, RiskLevel::Write, RiskLevel::Execute] {
|
||||
assert_eq!(
|
||||
decide_permission(PermissionMode::FullAccess, r),
|
||||
PermissionAction::AutoAllow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_parsing_falls_back_to_default() {
|
||||
assert_eq!(PermissionMode::from_wire("accept_edits"), PermissionMode::AcceptEdits);
|
||||
assert_eq!(PermissionMode::from_wire("full_access"), PermissionMode::FullAccess);
|
||||
assert_eq!(PermissionMode::from_wire("garbage"), PermissionMode::Default);
|
||||
assert_eq!(PermissionMode::from_wire(""), PermissionMode::Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Per-session filesystem sandbox for the Agent Workbench.
|
||||
//!
|
||||
//! Every agent session is confined to an isolated working directory
|
||||
//! (`data/agent_sessions/{session_id}/`). Tool path arguments are validated to
|
||||
//! resolve *within* that directory before the tool is allowed to run, so one
|
||||
//! tenant's agent can never read or write another tenant's files (or escape to
|
||||
//! the host filesystem via `..`/symlinks/absolute paths).
|
||||
//!
|
||||
//! Adapted from the AQBot agent crate's `security.rs`. SDK-free: the route layer
|
||||
//! turns an [`Err`] into a permission denial.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Root directory that holds all per-session sandboxes, relative to the server's
|
||||
/// working directory.
|
||||
pub const SESSIONS_ROOT: &str = "data/agent_sessions";
|
||||
|
||||
/// Compute the sandbox directory for a session id (not yet created on disk).
|
||||
pub fn session_dir(session_id: &str) -> PathBuf {
|
||||
Path::new(SESSIONS_ROOT).join(sanitize_session_id(session_id))
|
||||
}
|
||||
|
||||
/// Create the sandbox directory for a session if missing, returning its
|
||||
/// canonical absolute path (suitable for use as the agent cwd).
|
||||
pub fn ensure_session_dir(session_id: &str) -> std::io::Result<PathBuf> {
|
||||
let dir = session_dir(session_id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
dir.canonicalize()
|
||||
}
|
||||
|
||||
/// Strip anything that could let a session id traverse outside SESSIONS_ROOT.
|
||||
/// Keeps only `[A-Za-z0-9_-]`; everything else becomes `_`.
|
||||
fn sanitize_session_id(session_id: &str) -> String {
|
||||
let cleaned: String = session_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
"default".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that `path` (absolute or relative to `cwd`) resolves to a location
|
||||
/// within `cwd`. Returns the resolved path on success, or an error message
|
||||
/// describing the escape attempt.
|
||||
///
|
||||
/// For existing paths the target is canonicalized (resolving symlinks). For
|
||||
/// not-yet-existing paths the nearest existing ancestor is canonicalized and
|
||||
/// checked, so writes to new files inside the sandbox are permitted while writes
|
||||
/// that would escape it are rejected.
|
||||
///
|
||||
/// Symlinks are handled explicitly with `lstat` (not `exists()`, which follows
|
||||
/// links): any component that is a symlink — including a *dangling* one whose
|
||||
/// target does not exist yet — has its target resolved and is rejected if it
|
||||
/// points outside the sandbox. This stops an approved `ln -s /outside x` from
|
||||
/// being written through to escape (a write to `x` would otherwise follow the
|
||||
/// link). Note this closes the escape, not the check-vs-open TOCTOU race; the
|
||||
/// actual open happens inside the tool and would need `O_NOFOLLOW`/`openat2`
|
||||
/// there to be fully atomic.
|
||||
pub fn validate_path_within_cwd(path: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||
let cwd_canonical = cwd
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("cannot resolve sandbox directory '{}': {e}", cwd.display()))?;
|
||||
|
||||
let target = Path::new(path);
|
||||
let resolved = if target.is_absolute() {
|
||||
target.to_path_buf()
|
||||
} else {
|
||||
cwd_canonical.join(target)
|
||||
};
|
||||
|
||||
// Final component: if it is a symlink (dangling or not), trust only its
|
||||
// target. lstat does not follow the link, so a dangling symlink is detected
|
||||
// here instead of slipping into the not-yet-existing branch below.
|
||||
if let Ok(meta) = std::fs::symlink_metadata(&resolved) {
|
||||
if meta.file_type().is_symlink() {
|
||||
let tgt = symlink_real_target(&resolved)?;
|
||||
return if tgt.starts_with(&cwd_canonical) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"path '{path}' is a symlink that escapes the session sandbox"
|
||||
))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.exists() {
|
||||
let canonical = resolved
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("cannot resolve path '{path}': {e}"))?;
|
||||
return if canonical.starts_with(&cwd_canonical) {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err(format!(
|
||||
"path '{path}' resolves outside the session sandbox"
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// Not-yet-existing path: walk up to the nearest existing ancestor. Use
|
||||
// lstat so an intermediate *dangling* symlink directory is caught (an
|
||||
// existing symlink ancestor with a live target is already caught by
|
||||
// canonicalize, but a dangling one would otherwise be skipped over).
|
||||
let mut ancestor = resolved.parent();
|
||||
while let Some(dir) = ancestor {
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
let tgt = symlink_real_target(dir)?;
|
||||
return if tgt.starts_with(&cwd_canonical) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"path '{path}' traverses a symlink that escapes the session sandbox"
|
||||
))
|
||||
};
|
||||
}
|
||||
Ok(_) => {
|
||||
// Existing real directory/file: canonicalize and bound-check.
|
||||
let anc_canonical = dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("cannot resolve ancestor of '{path}': {e}"))?;
|
||||
return if anc_canonical.starts_with(&cwd_canonical) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!("path '{path}' is outside the session sandbox"))
|
||||
};
|
||||
}
|
||||
// Component does not exist in any form yet: keep walking up.
|
||||
Err(_) => {}
|
||||
}
|
||||
ancestor = dir.parent();
|
||||
}
|
||||
Err(format!("cannot verify path '{path}' against the session sandbox"))
|
||||
}
|
||||
|
||||
/// Resolve where a (possibly dangling) symlink points, as a real absolute path,
|
||||
/// without requiring the target to exist: read the link, make it absolute
|
||||
/// relative to the link's parent, then canonicalize its nearest existing
|
||||
/// ancestor and re-append the missing remainder.
|
||||
fn symlink_real_target(link: &Path) -> Result<PathBuf, String> {
|
||||
let raw = std::fs::read_link(link)
|
||||
.map_err(|e| format!("cannot read symlink '{}': {e}", link.display()))?;
|
||||
let abs = if raw.is_absolute() {
|
||||
raw
|
||||
} else {
|
||||
link.parent().unwrap_or_else(|| Path::new("/")).join(raw)
|
||||
};
|
||||
real_location(&abs)
|
||||
.ok_or_else(|| format!("cannot resolve symlink target '{}'", abs.display()))
|
||||
}
|
||||
|
||||
/// Real absolute location of a path that may not fully exist: canonicalize the
|
||||
/// nearest existing ancestor (resolving any symlinks/`..` in the existing part)
|
||||
/// and re-append the non-existing remainder lexically.
|
||||
fn real_location(p: &Path) -> Option<PathBuf> {
|
||||
if let Ok(c) = p.canonicalize() {
|
||||
return Some(c);
|
||||
}
|
||||
let mut remainder: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut cur = p.to_path_buf();
|
||||
loop {
|
||||
let parent = cur.parent()?;
|
||||
if let Some(name) = cur.file_name() {
|
||||
remainder.push(name.to_os_string());
|
||||
}
|
||||
if let Ok(mut base) = parent.canonicalize() {
|
||||
while let Some(name) = remainder.pop() {
|
||||
base.push(name);
|
||||
}
|
||||
return Some(base);
|
||||
}
|
||||
cur = parent.to_path_buf();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a tool's path-bearing arguments stay within the sandbox.
|
||||
///
|
||||
/// Returns `Ok(())` when the tool either takes no path arguments or all of its
|
||||
/// path arguments resolve within `cwd`; returns `Err(reason)` on the first
|
||||
/// argument that escapes. Bash/shell are intentionally not path-checked here —
|
||||
/// they are gated by the approval flow and run with the sandbox dir as cwd.
|
||||
pub fn check_tool_paths(tool_name: &str, input: &Value, cwd: &Path) -> Result<(), String> {
|
||||
let n = tool_name.to_lowercase();
|
||||
match n.as_str() {
|
||||
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" | "create"
|
||||
| "create_file" | "delete" | "delete_file" | "rename" | "list_dir" | "listdir"
|
||||
| "multiedit" => {
|
||||
check_field(input, &["path", "file_path", "file"], cwd)?;
|
||||
check_field(input, &["new_path", "destination", "dest"], cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
"glob" | "glob_search" | "grep" | "search" | "ripgrep" => {
|
||||
// Optional explicit base path; the pattern itself is relative to cwd.
|
||||
check_field(input, &["path", "dir", "directory"], cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
// Command execution and unknown tools: no path arg to validate here.
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_field(input: &Value, keys: &[&str], cwd: &Path) -> Result<(), String> {
|
||||
for key in keys {
|
||||
if let Some(p) = input.get(*key).and_then(|v| v.as_str()) {
|
||||
validate_path_within_cwd(p, cwd)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp_sandbox() -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("agent_sb_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_session_id() {
|
||||
assert_eq!(sanitize_session_id("../../etc"), "______etc");
|
||||
assert_eq!(sanitize_session_id("abc-123_DEF"), "abc-123_DEF");
|
||||
assert_eq!(sanitize_session_id(""), "default");
|
||||
assert_eq!(sanitize_session_id("a/b"), "a_b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_path_inside_sandbox() {
|
||||
let sb = tmp_sandbox();
|
||||
assert!(validate_path_within_cwd("notes.txt", &sb).is_ok());
|
||||
assert!(validate_path_within_cwd("sub/dir/new.txt", &sb).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_traversal() {
|
||||
let sb = tmp_sandbox();
|
||||
assert!(validate_path_within_cwd("../escape.txt", &sb).is_err());
|
||||
assert!(validate_path_within_cwd("../../etc/passwd", &sb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_outside() {
|
||||
let sb = tmp_sandbox();
|
||||
assert!(validate_path_within_cwd("/etc/passwd", &sb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_tool_paths_blocks_escaping_write() {
|
||||
let sb = tmp_sandbox();
|
||||
let bad = json!({"path": "../../secret.txt", "content": "x"});
|
||||
assert!(check_tool_paths("write", &bad, &sb).is_err());
|
||||
|
||||
let ok = json!({"path": "out.txt", "content": "x"});
|
||||
assert!(check_tool_paths("write", &ok, &sb).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_tool_paths_ignores_bash() {
|
||||
let sb = tmp_sandbox();
|
||||
let input = json!({"command": "ls -la"});
|
||||
assert!(check_tool_paths("bash", &input, &sb).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_dangling_symlink_escape() {
|
||||
// Models an approved `ln -s /etc/cron.d/pwn evil` inside the sandbox,
|
||||
// whose target does not exist yet. A later Write to `evil` must NOT be
|
||||
// allowed to follow the link out of the sandbox.
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink("/etc/cron.d/pwn_dangling", sb.join("evil")).unwrap();
|
||||
let res = validate_path_within_cwd("evil", &sb);
|
||||
assert!(res.is_err(), "dangling symlink escape must be rejected: {res:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_dangling_symlink_to_absolute_outside() {
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink("/tmp/definitely_outside_xyz", sb.join("link")).unwrap();
|
||||
assert!(validate_path_within_cwd("link", &sb).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_relative_dangling_symlink_escape() {
|
||||
// `evil -> ../../escape` (target missing) must be rejected too.
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink("../../escape_target", sb.join("evil")).unwrap();
|
||||
assert!(validate_path_within_cwd("evil", &sb).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn allows_dangling_symlink_pointing_inside() {
|
||||
// A symlink whose (missing) target is still inside the sandbox is fine.
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink(sb.join("inside_target"), sb.join("ok_link")).unwrap();
|
||||
assert!(validate_path_within_cwd("ok_link", &sb).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_write_through_intermediate_dangling_symlink_dir() {
|
||||
// `evildir -> /etc` (dangling), then write to `evildir/pwn`.
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink("/etc/nonexistent_dir_xyz", sb.join("evildir")).unwrap();
|
||||
assert!(validate_path_within_cwd("evildir/pwn", &sb).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn check_tool_paths_blocks_dangling_symlink_write() {
|
||||
let sb = tmp_sandbox();
|
||||
std::os::unix::fs::symlink("/etc/cron.d/pwn2", sb.join("evil")).unwrap();
|
||||
let bad = json!({"path": "evil", "content": "* * * * * root sh"});
|
||||
assert!(check_tool_paths("write", &bad, &sb).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Persistence for Agent Workbench session accounting.
|
||||
//!
|
||||
//! Each agent session has one row in `agent_sessions` tracking cumulative token
|
||||
//! usage and cost across its turns. A session is created (idempotently) on its
|
||||
//! first chat request and its counters are advanced after every completed turn,
|
||||
//! using the usage/cost reported by the SDK `Result` event.
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// One agent session's persisted accounting state.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct AgentSession {
|
||||
pub id: String,
|
||||
pub user_id: Option<i64>,
|
||||
pub title: Option<String>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub total_tokens: i64,
|
||||
pub cost_usd: f64,
|
||||
pub num_turns: i64,
|
||||
pub message_count: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Token/cost delta produced by one completed agent turn.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct UsageDelta {
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub cost_usd: f64,
|
||||
pub num_turns: i64,
|
||||
}
|
||||
|
||||
/// Create the session row if it does not exist yet. Idempotent: an existing
|
||||
/// session keeps its counters and metadata untouched.
|
||||
pub async fn ensure_session(
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
user_id: Option<i64>,
|
||||
model: &str,
|
||||
permission_mode: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_sessions (id, user_id, model, permission_mode) \
|
||||
VALUES (?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO NOTHING",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(user_id)
|
||||
.bind(model)
|
||||
.bind(permission_mode)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance a session's cumulative counters by one turn's usage delta and bump
|
||||
/// `updated_at`. The user-visible message count is incremented by `messages`
|
||||
/// (typically 2: one user prompt + one assistant reply).
|
||||
pub async fn record_turn(
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
delta: UsageDelta,
|
||||
messages: i64,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE agent_sessions SET \
|
||||
input_tokens = input_tokens + ?, \
|
||||
output_tokens = output_tokens + ?, \
|
||||
total_tokens = total_tokens + ?, \
|
||||
cost_usd = cost_usd + ?, \
|
||||
num_turns = num_turns + ?, \
|
||||
message_count = message_count + ?, \
|
||||
updated_at = datetime('now') \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(delta.input_tokens)
|
||||
.bind(delta.output_tokens)
|
||||
.bind(delta.input_tokens + delta.output_tokens)
|
||||
.bind(delta.cost_usd)
|
||||
.bind(delta.num_turns)
|
||||
.bind(messages)
|
||||
.bind(session_id)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a single session by id (scoped to `user_id` when provided, so a user
|
||||
/// cannot read another tenant's session). Admin callers pass `None`.
|
||||
pub async fn get_session(
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
user_id: Option<i64>,
|
||||
) -> AppResult<Option<AgentSession>> {
|
||||
let row = match user_id {
|
||||
Some(uid) => {
|
||||
sqlx::query_as::<_, AgentSession>(
|
||||
"SELECT * FROM agent_sessions WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(uid)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, AgentSession>("SELECT * FROM agent_sessions WHERE id = ?")
|
||||
.bind(session_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../../../migrations/20260622000001_agent_sessions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_is_idempotent_and_accumulates() {
|
||||
let db = pool().await;
|
||||
ensure_session(&db, "s1", Some(7), "gpt-4o", "default")
|
||||
.await
|
||||
.unwrap();
|
||||
// Second ensure must not reset counters.
|
||||
record_turn(
|
||||
&db,
|
||||
"s1",
|
||||
UsageDelta { input_tokens: 100, output_tokens: 40, cost_usd: 0.5, num_turns: 3 },
|
||||
2,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_session(&db, "s1", Some(7), "gpt-4o", "default")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = get_session(&db, "s1", Some(7)).await.unwrap().unwrap();
|
||||
assert_eq!(s.input_tokens, 100);
|
||||
assert_eq!(s.output_tokens, 40);
|
||||
assert_eq!(s.total_tokens, 140);
|
||||
assert!((s.cost_usd - 0.5).abs() < 1e-9);
|
||||
assert_eq!(s.num_turns, 3);
|
||||
assert_eq!(s.message_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_turn_adds_across_turns() {
|
||||
let db = pool().await;
|
||||
ensure_session(&db, "s2", None, "sonnet", "default").await.unwrap();
|
||||
for _ in 0..3 {
|
||||
record_turn(
|
||||
&db,
|
||||
"s2",
|
||||
UsageDelta { input_tokens: 10, output_tokens: 5, cost_usd: 0.01, num_turns: 1 },
|
||||
2,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let s = get_session(&db, "s2", None).await.unwrap().unwrap();
|
||||
assert_eq!(s.total_tokens, 45);
|
||||
assert_eq!(s.num_turns, 3);
|
||||
assert_eq!(s.message_count, 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_session_is_user_scoped() {
|
||||
let db = pool().await;
|
||||
ensure_session(&db, "s3", Some(1), "m", "default").await.unwrap();
|
||||
// Wrong user cannot see it.
|
||||
assert!(get_session(&db, "s3", Some(2)).await.unwrap().is_none());
|
||||
// Owner can.
|
||||
assert!(get_session(&db, "s3", Some(1)).await.unwrap().is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod announcement;
|
||||
pub mod agent_workbench;
|
||||
pub mod billing;
|
||||
pub mod builtin_keys;
|
||||
pub mod comfyui_process;
|
||||
|
||||
@@ -42,6 +42,7 @@ const Models = lazy(() => import('./pages/Models.js'));
|
||||
const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||
const AgentWorkbench = lazy(() => import('./pages/AgentWorkbench.js'));
|
||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
|
||||
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
|
||||
@@ -626,6 +627,7 @@ export const sidebarGroups: SidebarGroup[] = [
|
||||
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
|
||||
{ to: '/workflows', label: '工作流', icon: navIcons.workflow },
|
||||
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
|
||||
{ to: '/agent-workbench', label: 'AI Agent', icon: navIcons.assistant },
|
||||
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace },
|
||||
],
|
||||
},
|
||||
@@ -658,6 +660,7 @@ export const userSidebarGroups = [
|
||||
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/user/agent-workbench', label: 'AI Agent', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
||||
],
|
||||
},
|
||||
@@ -1667,6 +1670,7 @@ function AppShell() {
|
||||
<Route path="/workflows" element={<Workflows />} />
|
||||
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
|
||||
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/agent-workbench" element={<AgentWorkbench />} />
|
||||
<Route path="/workspace" element={<Workspace />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
@@ -1690,6 +1694,7 @@ function AppShell() {
|
||||
<Route path="/user/workflows" element={<Workflows />} />
|
||||
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
|
||||
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/user/agent-workbench" element={<AgentWorkbench />} />
|
||||
<Route path="/user/workspace" element={<Workspace />} />
|
||||
<Route path="/user/about" element={<About />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
|
||||
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
|
||||
// Streams assistant text, renders tool-call cards, surfaces interactive
|
||||
// approval prompts for risky tools, and shows running session token/cost.
|
||||
|
||||
type PermissionMode = 'default' | 'accept_edits' | 'full_access';
|
||||
|
||||
type ToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
denied?: string;
|
||||
};
|
||||
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
tools: ToolCall[];
|
||||
};
|
||||
|
||||
type ApprovalPrompt = {
|
||||
requestId: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
risk: string;
|
||||
};
|
||||
|
||||
type SessionUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
numTurns: number;
|
||||
};
|
||||
|
||||
type SseEvent = { type: string; data?: Record<string, unknown> };
|
||||
|
||||
const MODE_LABELS: Record<PermissionMode, string> = {
|
||||
default: '标准审核',
|
||||
accept_edits: '自动接受编辑',
|
||||
full_access: '完全放行',
|
||||
};
|
||||
|
||||
/** Read a `data:`-framed SSE stream, invoking `onEvent` per JSON line. */
|
||||
async function readSse(response: Response, onEvent: (e: SseEvent) => void): Promise<void> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('浏览器无法读取流式响应');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(data) as SseEvent);
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
export default function AgentWorkbench() {
|
||||
const toast = useToast();
|
||||
const location = useLocation();
|
||||
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [mode, setMode] = useState<PermissionMode>('default');
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [usage, setUsage] = useState<SessionUsage | null>(null);
|
||||
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, approval]);
|
||||
|
||||
const authToken = useCallback(
|
||||
() => getSessionToken(audience, localStorage) ?? '',
|
||||
[audience],
|
||||
);
|
||||
|
||||
// Mutate the last assistant message in place as stream events arrive.
|
||||
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
|
||||
setMessages((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const next = prev.slice();
|
||||
const last = { ...next[next.length - 1] };
|
||||
if (last.role !== 'assistant') return prev;
|
||||
last.tools = last.tools.slice();
|
||||
mut(last);
|
||||
next[next.length - 1] = last;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
async (requestId: string, approved: boolean) => {
|
||||
setApproval(null);
|
||||
try {
|
||||
await fetch('/api/agent/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken()}`,
|
||||
},
|
||||
body: JSON.stringify({ request_id: requestId, approved }),
|
||||
});
|
||||
} catch {
|
||||
toast.error('提交审批失败');
|
||||
}
|
||||
},
|
||||
[authToken, toast],
|
||||
);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(e: SseEvent) => {
|
||||
const d = e.data ?? {};
|
||||
switch (e.type) {
|
||||
case 'session':
|
||||
if (typeof d.session_id === 'string') setSessionId(d.session_id);
|
||||
break;
|
||||
case 'text':
|
||||
if (typeof d.text === 'string') {
|
||||
const chunk = d.text;
|
||||
updateAssistant((m) => {
|
||||
m.text += chunk;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'tool_use':
|
||||
updateAssistant((m) => {
|
||||
m.tools.push({
|
||||
id: String(d.id ?? ''),
|
||||
name: String(d.name ?? ''),
|
||||
input: d.input,
|
||||
});
|
||||
});
|
||||
break;
|
||||
case 'tool_result':
|
||||
updateAssistant((m) => {
|
||||
const tc = m.tools.find((t) => t.id === d.tool_use_id);
|
||||
if (tc) {
|
||||
tc.result = String(d.content ?? '');
|
||||
tc.isError = Boolean(d.is_error);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'tool_denied':
|
||||
updateAssistant((m) => {
|
||||
const tc = m.tools.find((t) => t.name === d.name && !t.result && !t.denied);
|
||||
if (tc) tc.denied = String(d.reason ?? '已拒绝');
|
||||
});
|
||||
break;
|
||||
case 'approval_request':
|
||||
setApproval({
|
||||
requestId: String(d.request_id ?? ''),
|
||||
name: String(d.name ?? ''),
|
||||
input: d.input,
|
||||
risk: String(d.risk ?? ''),
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
setUsage({
|
||||
inputTokens: Number(d.input_tokens ?? 0),
|
||||
outputTokens: Number(d.output_tokens ?? 0),
|
||||
totalTokens: Number(d.total_tokens ?? 0),
|
||||
costUsd: Number(d.cost_usd ?? 0),
|
||||
numTurns: Number(d.num_turns ?? 0),
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
toast.error(String(d.message ?? '执行出错'));
|
||||
updateAssistant((m) => {
|
||||
m.text += `\n\n[错误: ${String(d.message ?? '')}]`;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toast, updateAssistant],
|
||||
);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const prompt = input.trim();
|
||||
if (!prompt || running) return;
|
||||
const token = authToken();
|
||||
if (!token) {
|
||||
toast.error('未登录或会话已过期');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setApproval(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'user', text: prompt, tools: [] },
|
||||
{ role: 'assistant', text: '', tools: [] },
|
||||
]);
|
||||
setInput('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: prompt,
|
||||
session_id: sessionId,
|
||||
permission_mode: mode,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(detail || `HTTP ${response.status}`);
|
||||
}
|
||||
await readSse(response, handleEvent);
|
||||
} catch (err) {
|
||||
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
updateAssistant((m) => {
|
||||
m.text += '\n\n[连接中断]';
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setApproval(null);
|
||||
}
|
||||
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (running) return;
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
setUsage(null);
|
||||
setApproval(null);
|
||||
}, [running]);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>AI Agent 工作台</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--text-secondary, #888)' }}>
|
||||
服务端内置 agent,可读写文件 / 执行命令(沙箱隔离 + 权限审批)
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
disabled={running}
|
||||
onChange={(e) => setMode(e.target.value as PermissionMode)}
|
||||
style={{ padding: '4px 8px', borderRadius: 6 }}
|
||||
>
|
||||
{(Object.keys(MODE_LABELS) as PermissionMode[]).map((m) => (
|
||||
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={newSession} disabled={running} style={{ padding: '4px 12px', borderRadius: 6 }}>
|
||||
新会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
{usage && (
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-secondary, #888)', flexWrap: 'wrap' }}>
|
||||
<span>会话: <code>{sessionId?.slice(0, 8) ?? '—'}</code></span>
|
||||
<span>轮次: {usage.numTurns}</span>
|
||||
<span>tokens: {usage.totalTokens} (in {usage.inputTokens} / out {usage.outputTokens})</span>
|
||||
<span>成本: {formatCost(usage.costUsd)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--border-color, #2a2a2a)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
minHeight: 240,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
|
||||
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}>
|
||||
{m.role === 'user' ? '你' : 'Agent'}
|
||||
</div>
|
||||
{m.text && (
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
|
||||
)}
|
||||
{m.tools.map((t) => (
|
||||
<ToolCard key={t.id} tool={t} />
|
||||
))}
|
||||
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Approval modal */}
|
||||
{approval && (
|
||||
<ApprovalModal
|
||||
prompt={approval}
|
||||
onApprove={() => resolveApproval(approval.requestId, true)}
|
||||
onDeny={() => resolveApproval(approval.requestId, false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<textarea
|
||||
value={input}
|
||||
disabled={running}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)"
|
||||
rows={3}
|
||||
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={running || !input.trim()}
|
||||
style={{
|
||||
padding: '0 20px',
|
||||
borderRadius: 8,
|
||||
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: running ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{running ? '运行中…' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCard({ tool }: { tool: ToolCall }) {
|
||||
const border = tool.denied
|
||||
? '#f59e0b'
|
||||
: tool.isError
|
||||
? '#ef4444'
|
||||
: tool.result
|
||||
? '#22c55e'
|
||||
: 'var(--border-color, #2a2a2a)';
|
||||
return (
|
||||
<div style={{ border: `1px solid ${border}`, borderRadius: 8, padding: 10, fontSize: 12, background: 'var(--surface, rgba(255,255,255,0.02))' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>🔧 {tool.name}</span>
|
||||
{!tool.result && !tool.denied && <span style={{ color: 'var(--text-secondary, #888)' }}>· 运行中</span>}
|
||||
{tool.denied && <span style={{ color: '#f59e0b' }}>· 已拒绝</span>}
|
||||
</div>
|
||||
<pre style={{ margin: '4px 0', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--text-secondary, #aaa)' }}>
|
||||
{typeof tool.input === 'string' ? tool.input : JSON.stringify(tool.input, null, 2)}
|
||||
</pre>
|
||||
{tool.denied && <div style={{ color: '#f59e0b' }}>{tool.denied}</div>}
|
||||
{tool.result && (
|
||||
<pre
|
||||
style={{
|
||||
margin: '6px 0 0',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
color: tool.isError ? '#ef4444' : 'inherit',
|
||||
}}
|
||||
>
|
||||
{tool.result}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalModal({
|
||||
prompt,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: {
|
||||
prompt: ApprovalPrompt;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg, #1a1a1a)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: 'min(520px, 92vw)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 16 }}>需要审批:{prompt.name}</h3>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-secondary, #888)' }}>
|
||||
Agent 请求执行一个 <strong>{prompt.risk}</strong> 级别的工具。请确认参数后决定是否允许。
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{typeof prompt.input === 'string' ? prompt.input : JSON.stringify(prompt.input, null, 2)}
|
||||
</pre>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
<button onClick={onDeny} style={{ padding: '8px 16px', borderRadius: 8 }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
onClick={onApprove}
|
||||
style={{ padding: '8px 16px', borderRadius: 8, background: '#22c55e', color: '#fff', border: 'none' }}
|
||||
>
|
||||
允许执行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Integration tests for the Agent Workbench approval registry.
|
||||
//!
|
||||
//! These run against the library's public API (not the in-crate `#[cfg(test)]`
|
||||
//! modules), so they compile and execute independently of unrelated test
|
||||
//! fixtures elsewhere in the crate. They pin the core safety invariant of the
|
||||
//! atomic approval state machine:
|
||||
//!
|
||||
//! the value `/api/agent/approve` reports back (`resolve` -> bool) and the
|
||||
//! decision the agent loop executes (`wait_for_decision` -> SettledState)
|
||||
//! are derived from the *same* atomic, so they can never disagree.
|
||||
|
||||
use boosapi::services::agent_workbench::approval::{
|
||||
register, resolve, wait_for_decision, ApprovalOutcome, SettledState,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_then_wait_observes_approved() {
|
||||
let (entry, _guard) = register("it-approve");
|
||||
assert!(resolve("it-approve", ApprovalOutcome::Approve));
|
||||
assert_eq!(
|
||||
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||
SettledState::Approved
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_then_wait_observes_denied() {
|
||||
let (entry, _guard) = register("it-deny");
|
||||
assert!(resolve("it-deny", ApprovalOutcome::Deny));
|
||||
assert_eq!(
|
||||
wait_for_decision(&entry, Duration::from_secs(5)).await,
|
||||
SettledState::Denied
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_denies_and_late_click_is_truthfully_rejected() {
|
||||
let (entry, _guard) = register("it-timeout");
|
||||
// No resolve arrives before the (tiny) timeout.
|
||||
assert_eq!(
|
||||
wait_for_decision(&entry, Duration::from_millis(20)).await,
|
||||
SettledState::TimedOut
|
||||
);
|
||||
// A late click must be told ok:false — the timeout already claimed it.
|
||||
assert!(!resolve("it-timeout", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_id_resolves_false() {
|
||||
assert!(!resolve("it-unknown", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_drop_cleans_up_registry() {
|
||||
{
|
||||
let (_entry, _guard) = register("it-guard");
|
||||
}
|
||||
assert!(!resolve("it-guard", ApprovalOutcome::Approve));
|
||||
}
|
||||
|
||||
/// The invariant under contention: race a `resolve(Approve)` against a
|
||||
/// near-zero timeout thousands of times. The frontend-reported success
|
||||
/// (`resolve` -> true) must hold if and only if the executed decision is
|
||||
/// `Approved`; otherwise the decision must be `TimedOut`. The mixed state
|
||||
/// (frontend told "approved" while the loop denied, or vice versa) must never
|
||||
/// occur — that was the bug the atomic state machine fixes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn resolve_and_timeout_never_disagree() {
|
||||
for i in 0..5000 {
|
||||
let id = format!("it-race-{i}");
|
||||
let (entry, _guard) = register(&id);
|
||||
let id2 = id.clone();
|
||||
let resolver = tokio::spawn(async move { resolve(&id2, ApprovalOutcome::Approve) });
|
||||
let decided = wait_for_decision(&entry, Duration::from_nanos(1)).await;
|
||||
let frontend_ok = resolver.await.unwrap();
|
||||
match (frontend_ok, decided) {
|
||||
(true, SettledState::Approved) => {}
|
||||
(false, SettledState::TimedOut) => {}
|
||||
other => panic!("inconsistent outcome at i={i}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Integration tests for OS-level (bubblewrap) confinement of the agent Bash
|
||||
//! tool. These actually invoke `SandboxedBashTool::call` and assert that, in a
|
||||
//! review mode, the kernel boundary blocks filesystem and network escapes while
|
||||
//! legitimate in-sandbox work succeeds.
|
||||
//!
|
||||
//! The escape tests require a working `bwrap`; when it is absent the confined
|
||||
//! path fails closed (asserted separately) and the live-escape tests skip with
|
||||
//! a printed notice rather than producing a false pass.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use boosapi::services::agent_workbench::bash_sandbox::{build_bwrap_args, SandboxedBashTool};
|
||||
use boosapi::services::agent_workbench::permission::PermissionMode;
|
||||
use open_agent_sdk::types::{Tool, ToolUseContext};
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn bwrap_available() -> bool {
|
||||
["/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap"]
|
||||
.iter()
|
||||
.any(|p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
fn tmp_session() -> PathBuf {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("agent_bash_it_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
async fn run(tool: &SandboxedBashTool, command: &str) -> (String, bool) {
|
||||
let ctx = ToolUseContext::new(std::env::temp_dir().to_string_lossy().to_string());
|
||||
let res = tool
|
||||
.call(json!({ "command": command }), &ctx)
|
||||
.await
|
||||
.expect("tool call ok");
|
||||
(res.get_text(), res.is_error)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_args_pin_session_dir_and_isolation() {
|
||||
let args = build_bwrap_args(Path::new("/srv/sess/abc"), "id");
|
||||
let j = args.join(" ");
|
||||
assert!(j.contains("--bind /srv/sess/abc /srv/sess/abc"));
|
||||
assert!(j.contains("--chdir /srv/sess/abc"));
|
||||
assert!(args.iter().any(|a| a == "--unshare-all"));
|
||||
assert!(!j.contains("/etc"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_mode_blocks_reading_etc_passwd() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||
let (out, _is_err) = run(&tool, "cat /etc/passwd").await;
|
||||
// The host's /etc is never mounted, so the read cannot succeed.
|
||||
assert!(
|
||||
!out.contains("root:") && !out.contains(":/bin/"),
|
||||
"should not see host passwd contents, got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_mode_blocks_listing_root() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||
let (out, _) = run(&tool, "ls /").await;
|
||||
// /etc, /root, /home must be absent from the sandbox root.
|
||||
assert!(!out.contains("etc"), "sandbox root must not expose /etc: {out}");
|
||||
assert!(!out.contains("root"), "sandbox root must not expose /root: {out}");
|
||||
assert!(!out.contains("home"), "sandbox root must not expose /home: {out}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_mode_blocks_writing_outside_sandbox() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||
let (_out, is_err) = run(&tool, "echo pwned > /etc/cron.d/x").await;
|
||||
assert!(is_err, "writing outside the sandbox must fail");
|
||||
// And the host file must not exist.
|
||||
assert!(!Path::new("/etc/cron.d/x").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_mode_blocks_network() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::Default);
|
||||
let (out, _) = run(&tool, "getent hosts example.com || echo NET-BLOCKED").await;
|
||||
assert!(out.contains("NET-BLOCKED"), "network should be unshared: {out}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_mode_allows_work_inside_sandbox() {
|
||||
if !bwrap_available() {
|
||||
eprintln!("SKIP: bwrap not available");
|
||||
return;
|
||||
}
|
||||
let sd = tmp_session();
|
||||
let tool = SandboxedBashTool::new(sd.clone(), PermissionMode::Default);
|
||||
let (out, is_err) = run(&tool, "echo hello > note.txt && cat note.txt").await;
|
||||
assert!(!is_err, "in-sandbox work should succeed: {out}");
|
||||
assert!(out.contains("hello"));
|
||||
// The file lands in the real session dir (bound rw).
|
||||
assert!(sd.join("note.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_mode_is_unconfined() {
|
||||
// FullAccess is the explicitly-dangerous mode: it should NOT be sandboxed,
|
||||
// so it can read the host (this documents the intended escape hatch).
|
||||
let tool = SandboxedBashTool::new(tmp_session(), PermissionMode::FullAccess);
|
||||
let (out, _) = run(&tool, "test -e /etc && echo HOST-VISIBLE || echo no").await;
|
||||
assert!(out.contains("HOST-VISIBLE"), "full access should see host: {out}");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Integration test for the dangling-symlink write-escape fix.
|
||||
//!
|
||||
//! Runs against the library's public API so it compiles/executes independently
|
||||
//! of unrelated in-crate test fixtures. Reproduces the concrete escape an
|
||||
//! approved `ln -s` could otherwise open: a symlink whose target does not exist
|
||||
//! yet (so `exists()` is false and `canonicalize()` fails) must still be
|
||||
//! rejected when it points outside the per-session sandbox, instead of being
|
||||
//! treated as a brand-new in-sandbox path that a Write would follow out.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use boosapi::services::agent_workbench::sandbox::{check_tool_paths, validate_path_within_cwd};
|
||||
use serde_json::json;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp_sandbox() -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("agent_it_sb_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_symlink_to_absolute_target_is_rejected() {
|
||||
let sb = tmp_sandbox();
|
||||
symlink("/etc/cron.d/pwn_it", sb.join("evil")).unwrap();
|
||||
let res = validate_path_within_cwd("evil", &sb);
|
||||
assert!(res.is_err(), "expected rejection, got {res:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_relative_symlink_escape_is_rejected() {
|
||||
let sb = tmp_sandbox();
|
||||
symlink("../../escape_it", sb.join("evil")).unwrap();
|
||||
assert!(validate_path_within_cwd("evil", &sb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tool_through_dangling_symlink_is_blocked() {
|
||||
let sb = tmp_sandbox();
|
||||
symlink("/etc/cron.d/pwn_it2", sb.join("evil")).unwrap();
|
||||
let input = json!({ "path": "evil", "content": "* * * * * root sh -c id" });
|
||||
assert!(check_tool_paths("write", &input, &sb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_through_intermediate_dangling_symlink_dir_is_blocked() {
|
||||
let sb = tmp_sandbox();
|
||||
symlink("/etc/nonexistent_it_dir", sb.join("evildir")).unwrap();
|
||||
let input = json!({ "path": "evildir/pwn", "content": "x" });
|
||||
assert!(check_tool_paths("write", &input, &sb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_symlink_pointing_inside_sandbox_is_allowed() {
|
||||
let sb = tmp_sandbox();
|
||||
symlink(sb.join("future_file"), sb.join("ok_link")).unwrap();
|
||||
assert!(validate_path_within_cwd("ok_link", &sb).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_new_file_still_allowed() {
|
||||
let sb = tmp_sandbox();
|
||||
assert!(validate_path_within_cwd("notes/today.md", &sb).is_ok());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user