Compare commits
65 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 | |||
| bbf0a3b81a | |||
| 334ed04a6a | |||
| 7b1167bd75 | |||
| 8a55efbfb6 | |||
| fd4c1f0ac1 | |||
| 60ecd8a13c | |||
| 441adcf489 | |||
| 7e824a6d45 | |||
| b2f6b875a0 | |||
| 86a2c51b31 | |||
| edc125f79c | |||
| 1d19401424 | |||
| 92d38bb309 | |||
| 55188e51ff | |||
| 96b8d2c399 | |||
| 284cf84a47 | |||
| 973396aac4 | |||
| 1598e6c758 | |||
| 07e31e0f7c | |||
| bfafc8f226 | |||
| 230eef96ed | |||
| 054343f489 | |||
| 3524a165e3 | |||
| 53f99affec | |||
| 30b7b12682 | |||
| 937442df04 | |||
| 79ade45c25 | |||
| ff514061d7 | |||
| 8ca6c2ad4f | |||
| d850da64c3 | |||
| 08a2980f5f | |||
| 7758858141 | |||
| 8676b5486b | |||
| e90beda1a5 | |||
| 03eea2e267 | |||
| 70c11a15cd | |||
| 17cd0691ca | |||
| 2d273a03ad | |||
| e1d0ade434 | |||
| e8508cb056 |
@@ -22,3 +22,12 @@
|
|||||||
# Default to all CPUs for parallel codegen. Override with --jobs N if
|
# Default to all CPUs for parallel codegen. Override with --jobs N if
|
||||||
# you need to leave headroom for other work.
|
# you need to leave headroom for other work.
|
||||||
# jobs = 8
|
# 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
+57
@@ -390,6 +390,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
"axum 0.8.9",
|
"axum 0.8.9",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -407,12 +408,14 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"open-agent-sdk",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rstest",
|
"rstest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -426,6 +429,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tracing-test",
|
"tracing-test",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
@@ -1823,6 +1827,31 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
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]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.80"
|
||||||
@@ -2528,6 +2557,19 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3457,6 +3499,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3790,6 +3838,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ sqlx = { version = "0.8", features = [
|
|||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
|
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"] }
|
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -76,6 +78,12 @@ hex = "0.4"
|
|||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
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]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
BIN
Binary file not shown.
+41
-4
@@ -227,6 +227,24 @@ configure_fast_linker() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Enable sccache as the rustc wrapper when available. sccache caches compiled
|
||||||
|
# crates keyed by source + flags, so dependency rebuilds / clean builds / branch
|
||||||
|
# switches reuse prior output. It transparently skips incremental crates, so the
|
||||||
|
# dev profile still relies mainly on incremental; release/fast-release benefit
|
||||||
|
# most. Set BOOSAPI_SCCACHE=off to disable.
|
||||||
|
configure_sccache() {
|
||||||
|
case "${BOOSAPI_SCCACHE:-auto}" in
|
||||||
|
off|none|0) return 0 ;;
|
||||||
|
esac
|
||||||
|
if [ -n "${RUSTC_WRAPPER:-}" ]; then
|
||||||
|
return 0 # respect an externally configured wrapper
|
||||||
|
fi
|
||||||
|
if command -v sccache >/dev/null 2>&1; then
|
||||||
|
export RUSTC_WRAPPER="sccache"
|
||||||
|
log "Rust 编译缓存: sccache(BOOSAPI_SCCACHE=off 可关闭)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
server_port() {
|
server_port() {
|
||||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||||
@@ -497,6 +515,7 @@ build_backend() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
configure_fast_linker
|
configure_fast_linker
|
||||||
|
configure_sccache
|
||||||
build_cargo_args
|
build_cargo_args
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
@@ -581,15 +600,33 @@ if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
build_frontend
|
|
||||||
build_public_website
|
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
|
# Production embeds frontend assets into the binary; keep the ordering
|
||||||
|
# explicit (frontend → workflow UI → backend) to avoid a race on the
|
||||||
|
# embedded files. No parallelism here.
|
||||||
|
build_frontend
|
||||||
|
build_public_website
|
||||||
ensure_workflow_ui_for_embedding
|
ensure_workflow_ui_for_embedding
|
||||||
|
build_backend
|
||||||
|
else
|
||||||
|
# Dev / normal builds: frontend (vite) + website (next) run in the
|
||||||
|
# background while the backend (cargo) builds in the foreground. The
|
||||||
|
# backend stays in the main shell so it can still set BINARY and other
|
||||||
|
# globals the launch step depends on. Total time ≈ max(frontend, backend).
|
||||||
|
log "并行构建:前端/官网(后台)+ 后端(前台)..."
|
||||||
|
( build_frontend && build_public_website ) &
|
||||||
|
fe_pid=$!
|
||||||
|
be_status=0
|
||||||
|
build_backend || be_status=$?
|
||||||
|
fe_status=0
|
||||||
|
wait "$fe_pid" || fe_status=$?
|
||||||
|
[ "$be_status" -eq 0 ] || fail "后端构建失败(exit $be_status)"
|
||||||
|
[ "$fe_status" -eq 0 ] || fail "前端/官网构建失败(exit $fe_status)"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
build_backend
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_backend
|
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
print_timing_summary
|
print_timing_summary
|
||||||
log "跳过启动(--check)"
|
log "跳过启动(--check)"
|
||||||
|
|||||||
+35
-35
@@ -1,3 +1,6 @@
|
|||||||
|
# 必填项保留;空字符串字段、有默认值的字段、与凭证相关的内容都从 toml 移走,
|
||||||
|
# 改用环境变量(BOOSAPI_<SECTION>__<KEY>)或后台 admin 界面写入数据库。
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 4000
|
port = 4000
|
||||||
@@ -8,13 +11,9 @@ cors_origins = ["*"]
|
|||||||
url = "sqlite:boosapi.db?mode=rwc"
|
url = "sqlite:boosapi.db?mode=rwc"
|
||||||
max_connections = 10
|
max_connections = 10
|
||||||
ssl = false
|
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]
|
[auth]
|
||||||
|
# 改成生产值前请先改 jwt_secret / auth_token / proxy_token,避免使用默认值。
|
||||||
jwt_secret = "boosapi-dev-secret-change-in-production"
|
jwt_secret = "boosapi-dev-secret-change-in-production"
|
||||||
jwt_expiry_hours = 72
|
jwt_expiry_hours = 72
|
||||||
auth_token = "boos4721"
|
auth_token = "boos4721"
|
||||||
@@ -25,15 +24,11 @@ upstream_timeout_secs = 300
|
|||||||
first_byte_timeout_secs = 60
|
first_byte_timeout_secs = 60
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
proxy_log_retention_days = 30
|
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]
|
[payment]
|
||||||
enabled = false
|
enabled = true
|
||||||
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
# provider: "comfy_cloud" | "stripe"
|
||||||
# Example: "https://tybossai.com"
|
provider = "comfy_cloud"
|
||||||
public_base_url = ""
|
|
||||||
product_name_prefix = "BossAI 账户充值"
|
product_name_prefix = "BossAI 账户充值"
|
||||||
currency = "usd"
|
currency = "usd"
|
||||||
min_amount_usd = 1.0
|
min_amount_usd = 1.0
|
||||||
@@ -42,35 +37,40 @@ order_timeout_minutes = 30
|
|||||||
max_pending_orders = 3
|
max_pending_orders = 3
|
||||||
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||||
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||||
# Prefer environment variables for secrets:
|
comfy_customer_api_base_url = "https://api.comfy.org"
|
||||||
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
# 凭证统一走环境变量或后台「支付管理」写入;不要写在这里:
|
||||||
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
# BOOSAPI_PAYMENT__PUBLIC_BASE_URL
|
||||||
stripe_secret_key = ""
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET
|
||||||
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]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
# backend: "boosapi" | "cloud" | "runninghub" | "external" | "docker"
|
||||||
|
backend = "cloud"
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
port = 8188
|
port = 8188
|
||||||
extra_args = []
|
extra_args = []
|
||||||
max_restarts = 5
|
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"
|
base_url = "https://cloud.comfy.org"
|
||||||
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
|
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,632 @@
|
|||||||
|
# Comfy Cloud 工作流执行适配器 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在 BoosAPI 内实现 ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器,让 Pixelle/comfykit 零改动通过 BoosAPI 调用 Comfy Cloud 执行工作流。
|
||||||
|
|
||||||
|
**Architecture:** 新增 `/comfy-cloud/{prompt,history,view}` 端点,内部翻译成 `cloud.comfy.org/api/*` + `X-API-Key`。Comfy Cloud 的 base_url + key 从站点管理(`platform='comfy_cloud'` 的 active 站点)读取。纯翻译函数单测,wiremock 集成测。
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, reqwest, sqlx, serde_json, wiremock, tokio)。
|
||||||
|
|
||||||
|
> **构建/测试:** `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。分支 `feat/comfy-cloud-adapter`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
- Create `src/services/comfy_cloud/mod.rs` — 模块导出。
|
||||||
|
- Create `src/services/comfy_cloud/site.rs` — `resolve_comfy_cloud_site` 读站点配置。
|
||||||
|
- Create `src/services/comfy_cloud/translate.rs` — `comfy_cloud_job_to_history` 纯翻译函数。
|
||||||
|
- Create `src/routes/comfy_cloud_adapter.rs` — 三个适配端点 + `routes()`。
|
||||||
|
- Modify `src/services/mod.rs` — 加 `pub mod comfy_cloud;`。
|
||||||
|
- Modify `src/routes/mod.rs` — 加 `mod comfy_cloud_adapter;` + `.merge(...)`。
|
||||||
|
- Modify `src/web/pages/Sites.tsx` — `SITE_PLATFORM_OPTIONS` 加 `comfy_cloud` 选项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TASKS -->
|
||||||
|
|
||||||
|
## Task 1: 站点配置读取 `resolve_comfy_cloud_site`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/comfy_cloud/mod.rs`
|
||||||
|
- Create: `src/services/comfy_cloud/site.rs`
|
||||||
|
- Modify: `src/services/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `src/services/mod.rs` 末尾加模块声明**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod comfy_cloud;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写 `src/services/comfy_cloud/mod.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Comfy Cloud 工作流执行适配器 service 层。
|
||||||
|
pub mod site;
|
||||||
|
pub mod translate;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写失败测试 `src/services/comfy_cloud/site.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// 解析 Comfy Cloud 站点配置,返回 (base_url, api_key)。
|
||||||
|
/// 取 platform='comfy_cloud' 且 status='active' 的站点。
|
||||||
|
pub async fn resolve_comfy_cloud_site(db: &SqlitePool) -> AppResult<(String, String)> {
|
||||||
|
let row: Option<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT url, api_key FROM sites
|
||||||
|
WHERE platform = 'comfy_cloud' AND status = 'active'
|
||||||
|
ORDER BY COALESCE(sort_order, 0), id LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
let (url, api_key) = row.ok_or_else(|| {
|
||||||
|
AppError::ServiceUnavailable("未配置 Comfy Cloud 站点(站点管理添加 comfy_cloud 平台)".into())
|
||||||
|
})?;
|
||||||
|
let api_key = api_key
|
||||||
|
.map(|k| k.trim().to_string())
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.ok_or_else(|| AppError::ServiceUnavailable("Comfy Cloud 站点缺少 API Key".into()))?;
|
||||||
|
let base_url = url.trim().trim_end_matches('/').to_string();
|
||||||
|
Ok((base_url, api_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolves_active_comfy_cloud_site() {
|
||||||
|
let pool = pool().await;
|
||||||
|
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc','https://cloud.comfy.org/','comfy_cloud','active','key-123')")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
let (url, key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||||
|
assert_eq!(url, "https://cloud.comfy.org");
|
||||||
|
assert_eq!(key, "key-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn errors_when_not_configured() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let r = resolve_comfy_cloud_site(&pool).await;
|
||||||
|
assert!(matches!(r, Err(AppError::ServiceUnavailable(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— 模块未挂载/函数未定义(编译错误)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 实现**
|
||||||
|
|
||||||
|
Step 3 的函数体即实现(测试与实现同文件)。确认 `src/services/comfy_cloud/mod.rs` 已 `pub mod site;`。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -10`
|
||||||
|
Expected: PASS(2 个测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/mod.rs src/services/comfy_cloud/mod.rs src/services/comfy_cloud/site.rs
|
||||||
|
git commit -m "feat(comfy-cloud): 站点配置读取 resolve_comfy_cloud_site"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T2 -->
|
||||||
|
|
||||||
|
## Task 2: 翻译函数 `comfy_cloud_job_to_history`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/comfy_cloud/translate.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试 `src/services/comfy_cloud/translate.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// 把 Comfy Cloud 的 job status(+ 完成时的 jobs detail outputs)翻译成
|
||||||
|
/// ComfyUI 原生 /history/{id} 的形状。
|
||||||
|
///
|
||||||
|
/// - status pending/in_progress → 返回空对象 {}(comfykit 据此继续轮询)
|
||||||
|
/// - status completed → { "{id}": { "status": {completed:true, status_str:"success"}, "outputs": {...} } }
|
||||||
|
/// - status failed/cancelled → { "{id}": { "status": {completed:false, status_str:"error"} } }
|
||||||
|
pub fn comfy_cloud_job_to_history(
|
||||||
|
prompt_id: &str,
|
||||||
|
status: &str,
|
||||||
|
outputs: Option<&Value>,
|
||||||
|
) -> Value {
|
||||||
|
match status {
|
||||||
|
"completed" => json!({
|
||||||
|
prompt_id: {
|
||||||
|
"status": { "completed": true, "status_str": "success" },
|
||||||
|
"outputs": outputs.cloned().unwrap_or_else(|| json!({})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"failed" | "cancelled" => json!({
|
||||||
|
prompt_id: {
|
||||||
|
"status": { "completed": false, "status_str": "error" },
|
||||||
|
"outputs": {},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
_ => json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_progress_returns_empty() {
|
||||||
|
assert_eq!(comfy_cloud_job_to_history("p1", "in_progress", None), json!({}));
|
||||||
|
assert_eq!(comfy_cloud_job_to_history("p1", "pending", None), json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_wraps_outputs() {
|
||||||
|
let outputs = json!({ "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } });
|
||||||
|
let h = comfy_cloud_job_to_history("p1", "completed", Some(&outputs));
|
||||||
|
assert_eq!(h["p1"]["status"]["completed"], true);
|
||||||
|
assert_eq!(h["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_marks_error() {
|
||||||
|
let h = comfy_cloud_job_to_history("p1", "failed", None);
|
||||||
|
assert_eq!(h["p1"]["status"]["status_str"], "error");
|
||||||
|
assert_eq!(h["p1"]["status"]["completed"], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— 模块未挂载/函数未定义。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现**
|
||||||
|
|
||||||
|
Step 1 的函数体即实现。确认 `mod.rs` 已 `pub mod translate;`(Task 1 Step 2 已加)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -10`
|
||||||
|
Expected: PASS(3 个测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/comfy_cloud/translate.rs
|
||||||
|
git commit -m "feat(comfy-cloud): job→ComfyUI history 翻译函数"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T3 -->
|
||||||
|
|
||||||
|
## Task 3: 适配端点骨架 + `POST /comfy-cloud/prompt`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/routes/comfy_cloud_adapter.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写骨架 + 测试 `src/routes/comfy_cloud_adapter.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
|
||||||
|
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Path, RawQuery, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::Response,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/comfy-cloud/prompt", post(submit_prompt))
|
||||||
|
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
|
||||||
|
.route("/comfy-cloud/view", get(get_view))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_prompt(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
|
||||||
|
let resp = client()
|
||||||
|
.post(format!("{base_url}/api/prompt"))
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
|
||||||
|
}
|
||||||
|
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use wiremock::matchers::{header as match_header, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
|
||||||
|
.bind(base_url).execute(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn submit_forwards_with_api_key() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/api/prompt"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||||
|
// 直接验证转发契约(handler 依赖 AppState,这里测底层契约)
|
||||||
|
let resp = client().post(format!("{base_url}/api/prompt"))
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.json(&serde_json::json!({ "prompt": {} }))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let v: Value = resp.json().await.unwrap();
|
||||||
|
assert_eq!(v["prompt_id"], "p1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注:`submit_prompt` handler 需 `AppState`,完整 handler 走 Task 6 联调验证;本 task 的
|
||||||
|
> wiremock 测试验证「X-API-Key 注入 + `/api/prompt` 路径 + prompt_id 透传」这一核心契约。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— 模块未挂载(`get_history`/`get_view` 未定义,编译错误)。
|
||||||
|
|
||||||
|
> 因 `routes()` 引用了 Task 4/5 的 `get_history`/`get_view`,本 task 先写它们的空桩
|
||||||
|
> 让编译过(Step 3),Task 4/5 再填实现。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 加 `get_history`/`get_view` 空桩(接在 `submit_prompt` 之后,让编译通过)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(prompt_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let _ = (&state, &prompt_id);
|
||||||
|
Ok(Json(serde_json::json!({})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_view(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let _ = (&state, &query);
|
||||||
|
Ok(Response::builder().status(StatusCode::NOT_IMPLEMENTED).body(Body::empty())
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||||
|
Expected: PASS(1 个测试)。
|
||||||
|
|
||||||
|
> 此时 `routes::comfy_cloud_adapter` 模块还没挂进 `routes/mod.rs`,测试是模块内单测,
|
||||||
|
> 不需要挂载即可跑。挂载在 Task 6。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/comfy_cloud_adapter.rs
|
||||||
|
git commit -m "feat(comfy-cloud): 适配端点骨架 + POST /prompt 转发"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T4 -->
|
||||||
|
|
||||||
|
## Task 4: `GET /comfy-cloud/history/{id}` 轮询翻译
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/comfy_cloud_adapter.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试(加到测试模块内)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_in_progress_then_completed() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
// 状态查询:completed
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/job/p1/status"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
// 详情:outputs
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/jobs/p1"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
|
||||||
|
})))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||||
|
assert_eq!(history["p1"]["status"]["completed"], true);
|
||||||
|
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::history 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— `fetch_history` 未定义。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现 `fetch_history` + 替换 `get_history` 空桩**
|
||||||
|
|
||||||
|
把 Task 3 的 `get_history` 空桩替换为:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||||
|
let http = client();
|
||||||
|
let status_resp = http
|
||||||
|
.get(format!("{base_url}/api/job/{prompt_id}/status"))
|
||||||
|
.header("X-API-Key", &api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !status_resp.status().is_success() {
|
||||||
|
let st = status_resp.status();
|
||||||
|
let body = status_resp.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
|
||||||
|
}
|
||||||
|
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
|
||||||
|
|
||||||
|
if status != "completed" {
|
||||||
|
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
|
||||||
|
}
|
||||||
|
let detail_resp = http
|
||||||
|
.get(format!("{base_url}/api/jobs/{prompt_id}"))
|
||||||
|
.header("X-API-Key", &api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let outputs = detail_json.get("outputs");
|
||||||
|
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(prompt_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||||
|
Expected: PASS(submit + history 共 2 个测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/comfy_cloud_adapter.rs
|
||||||
|
git commit -m "feat(comfy-cloud): /history 轮询翻译(status+jobs→history)"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T5 -->
|
||||||
|
|
||||||
|
## Task 5: `GET /comfy-cloud/view` 产出文件透传
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/comfy_cloud_adapter.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试(加到测试模块内)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn view_proxies_file_bytes() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/view"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200)
|
||||||
|
.insert_header("content-type", "image/png")
|
||||||
|
.set_body_bytes(b"PNGDATA".to_vec()))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
|
||||||
|
assert_eq!(mime, "image/png");
|
||||||
|
assert_eq!(bytes, b"PNGDATA");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::view 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— `fetch_view` 未定义。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现 `fetch_view` + 替换 `get_view` 空桩**
|
||||||
|
|
||||||
|
把 Task 3 的 `get_view` 空桩替换为:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||||
|
let q = query.unwrap_or("");
|
||||||
|
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
|
||||||
|
let resp = client()
|
||||||
|
.get(url)
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let st = resp.status();
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
|
||||||
|
}
|
||||||
|
let mime = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
|
||||||
|
Ok((mime, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_view(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, mime)
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> reqwest 默认跟随 302,所以 Comfy Cloud `/api/view` 的签名 URL 跳转会被自动跟随,
|
||||||
|
> 最终拿到文件字节。无需手动处理 Location。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||||
|
Expected: PASS(submit + history + view 共 3 个测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/comfy_cloud_adapter.rs
|
||||||
|
git commit -m "feat(comfy-cloud): /view 产出文件透传"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T6 -->
|
||||||
|
|
||||||
|
## Task 6: 挂载路由 + 前端平台选项 + 全量验证
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/mod.rs`
|
||||||
|
- Modify: `src/web/pages/Sites.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `src/routes/mod.rs` 顶部模块声明区加**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
mod comfy_cloud_adapter;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `build_router` 的 merge 链里加(`.merge(picture_agent::routes())` 附近)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.merge(comfy_cloud_adapter::routes())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 前端 `SITE_PLATFORM_OPTIONS` 加 comfy_cloud 选项**
|
||||||
|
|
||||||
|
在 `src/web/pages/Sites.tsx` 的 `SITE_PLATFORM_OPTIONS` 数组(约 line 700-710,
|
||||||
|
`{ value: 'cliproxyapi', ... }` 附近)加一项:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 后端全量构建 + comfy_cloud 测试**
|
||||||
|
|
||||||
|
Run: `cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib comfy_cloud 2>&1 | command grep "test result"`
|
||||||
|
Expected: `Finished`;comfy_cloud service + routes 测试全 PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 前端 typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck:web 2>&1 | tail -5`
|
||||||
|
Expected: 无错误。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/mod.rs src/web/pages/Sites.tsx
|
||||||
|
git commit -m "feat(comfy-cloud): 挂载适配路由 + 站点管理 comfy_cloud 平台选项"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 真实联调(手动验证,非自动测试)
|
||||||
|
|
||||||
|
> 这一步需要运行中的 BoosAPI + Pixelle,属手动验证。前 6 个 task 完成且 push 后执行。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 站点管理添加 Comfy Cloud 站点**
|
||||||
|
|
||||||
|
在管理控制台「站点管理」新增站点:平台选 `Comfy Cloud`,URL 填 `https://cloud.comfy.org`,
|
||||||
|
API Key 填有效的 Comfy Cloud key(`X-API-Key`),状态 active。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改 Pixelle config.yaml 的 comfyui_url**
|
||||||
|
|
||||||
|
把 `/opt/Pixelle-Video/config.yaml` 的 `comfyui.comfyui_url` 改为
|
||||||
|
`http://<boosapi-host>:<port>/comfy-cloud`(指向 BoosAPI 适配端点),
|
||||||
|
然后 `docker compose restart api`(在 `/opt/Pixelle-Video`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 跑一次 selfhost 视频生成**
|
||||||
|
|
||||||
|
从前端触发数字人/图片 selfhost 工作流生成视频,确认:
|
||||||
|
- 不再出现 `401 AuthenticationRequired`。
|
||||||
|
- 任务能提交(拿到 prompt_id)、轮询到 completed、取回产出文件。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 失败排查指引(若仍报错)**
|
||||||
|
|
||||||
|
- 仍 401:检查站点 api_key 是否为有效 Comfy Cloud key(`curl -H "X-API-Key: KEY" https://cloud.comfy.org/api/user` 应回 200)。
|
||||||
|
- 404/路径错:确认 Pixelle comfyui_url 末尾无多余 `/`,BoosAPI 适配端点前缀为 `/comfy-cloud`。
|
||||||
|
- 取不到产出:看 BoosAPI 日志里 `/api/jobs/{id}` 的响应 outputs 结构,必要时调 translate 映射。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 后端:`resolve_comfy_cloud_site` + `comfy_cloud_job_to_history` + 三个适配端点
|
||||||
|
(`/comfy-cloud/{prompt,history,view}`)实现并测试通过,`cargo build -j 2` 干净。
|
||||||
|
- 前端:站点管理可选 `comfy_cloud` 平台,`npm run typecheck:web` 干净。
|
||||||
|
- 联调:Pixelle 指向 BoosAPI 适配端点后,selfhost 视频生成不再 401 且能出片。
|
||||||
|
- 全部提交在 `feat/comfy-cloud-adapter` 分支。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 给 BoosAPI 数字人加「视频工作流」入口,让用户选 `comfy/video_digital_ltx2.json` 触发「肖像图+旁白音频→LTX2.3 说话视频」,贯穿前端→BoosAPI 后端→Pixelle 三层。
|
||||||
|
|
||||||
|
**Architecture:** 前端 `DigitalHuman.tsx` 加可选「视频工作流」选择器(列 `video_*`),提交 `api_video_workflow`;BoosAPI `digital_human.rs` 的 `pixelle_request_body` 透传该字段;Pixelle `asset_based.py` 对 `comfy/` 视频工作流也注入 `audio_path`。视频入口默认空=不启用,保持现有「图片+HTML 合成」向后兼容。
|
||||||
|
|
||||||
|
**Tech Stack:** React/TS(BoosAPI 前端)、Rust/axum(BoosAPI 后端)、Python(Pixelle,容器内)。
|
||||||
|
|
||||||
|
> **环境:** BoosAPI 仓库 `/root/metapi`,前端 `src/web`,后端 `src/routes`。Pixelle 在 Docker 容器 `pixelle-video-api`,源码 `/app/pixelle_video`(镜像内置,改后 `docker compose restart api`,备份到 `/opt/Pixelle-Video/patches/`)。构建:前端 `npm run typecheck:web`,后端 `cargo build -j 2`(-j 2 防 OOM)。分支 `feat/comfy-cloud-adapter`。
|
||||||
|
|
||||||
|
> **前置(已完成):** comfy-cloud 适配器、`comfy/video_digital_ltx2.json`(probe 通过)、media.py 转发 image_path。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
- Modify `src/web/pages/DigitalHuman.tsx` — 加 `apiVideoWorkflow` state + 「视频工作流」选择器 + payload 字段 + draft 持久化。
|
||||||
|
- Modify `src/routes/digital_human.rs` — `pixelle_request_body` 转发 `api_video_workflow`。
|
||||||
|
- Modify(容器内)`/app/pixelle_video/pipelines/asset_based.py` — comfy 视频工作流注入 audio_path。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TASKS -->
|
||||||
|
|
||||||
|
## Task 1: 前端加「视频工作流」选择器 + 提交字段
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/pages/DigitalHuman.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `DigitalHumanDraft` type 加字段(~line 34 附近,imageWorkflow 旁)**
|
||||||
|
|
||||||
|
找到 `imageWorkflow?: string;`(约 line 34),其后加一行:
|
||||||
|
```ts
|
||||||
|
apiVideoWorkflow?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 加 state(~line 370,imageWorkflow useState 旁)**
|
||||||
|
|
||||||
|
找到 `const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');`(约 line 370),其后加:
|
||||||
|
```ts
|
||||||
|
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: draft 持久化加字段(~line 462 写入 + ~line 480 依赖数组)**
|
||||||
|
|
||||||
|
在 `writeDigitalHumanDraft({...})` 对象里(约 line 462),`imageWorkflow,` 后加 `apiVideoWorkflow,`;
|
||||||
|
在该 useEffect 的依赖数组里(约 line 480),`imageWorkflow,` 后同样加 `apiVideoWorkflow,`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交 payload 加字段(~line 570 payload 对象)**
|
||||||
|
|
||||||
|
找到 payload 对象里的 `media_workflow: imageWorkflow || undefined,`(约 line 575),其后加:
|
||||||
|
```ts
|
||||||
|
api_video_workflow: apiVideoWorkflow || undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 加「视频工作流」选择器 UI(图片工作流 select 之后,~line 1012)**
|
||||||
|
|
||||||
|
找到「图片工作流」那个 `<label className="digital-human-field">...select...</label>` 块结束处
|
||||||
|
(约 line 1012,`</label>` 后),插入新选择器:
|
||||||
|
```tsx
|
||||||
|
<label className="digital-human-field">
|
||||||
|
<span>视频工作流(可选,生成说话视频)</span>
|
||||||
|
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
||||||
|
<option value="">不生成视频(仅图片合成)</option>
|
||||||
|
{mediaWorkflows
|
||||||
|
.filter((wf) => wf.name.toLowerCase().includes('video'))
|
||||||
|
.map((wf) => (
|
||||||
|
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: typecheck**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
|
||||||
|
Expected: 无 TS 错误。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/web/pages/DigitalHuman.tsx
|
||||||
|
git commit -m "feat(digital-human): 前端加视频工作流选择器(api_video_workflow)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T2 -->
|
||||||
|
|
||||||
|
## Task 2: BoosAPI 后端转发 api_video_workflow 给 Pixelle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/digital_human.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 pixelle_request_body 加转发(~line 2077,tts_workflow 块之后)**
|
||||||
|
|
||||||
|
找到 `pixelle_request_body` 里转发 tts_workflow 的块(约 line 2073-2078):
|
||||||
|
```rust
|
||||||
|
if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
||||||
|
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
|
||||||
|
{
|
||||||
|
request_body["tts_workflow"] = json!(value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
在它**之后**插入:
|
||||||
|
```rust
|
||||||
|
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||||
|
request_body["api_video_workflow"] = json!(value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 构建确认编译**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||||
|
Expected: `Finished`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 确认 optional_string 接受多 key(它已在文件内大量使用,签名兼容)**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && rtk proxy grep -n "fn optional_string" src/routes/digital_human.rs | head`
|
||||||
|
Expected: 找到定义,签名为 `(body, keys: &[&str])`(与 Step 1 用法一致)。若签名不同,按实际调整调用形式。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/routes/digital_human.rs
|
||||||
|
git commit -m "feat(digital-human): 后端转发 api_video_workflow 给 Pixelle
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T3 -->
|
||||||
|
|
||||||
|
## Task 3: Pixelle 给 comfy 视频工作流注入 audio_path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify(容器内):`/app/pixelle_video/pipelines/asset_based.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 定位音频注入块(~line 703)**
|
||||||
|
|
||||||
|
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'audio_driven_i2v' /app/pixelle_video/pipelines/asset_based.py"`
|
||||||
|
Expected: 命中 `if "audio_driven_i2v" in adapter_abilities:` 那行(约 703)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在该判断旁加 comfy 前缀分支(用 python 精确改写)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||||
|
p='/app/pixelle_video/pipelines/asset_based.py'
|
||||||
|
src=open(p).read()
|
||||||
|
anchor=' if \"audio_driven_i2v\" in adapter_abilities:\n api_video_params[\"audio_path\"] = frame.audio_path'
|
||||||
|
assert anchor in src, 'anchor not found'
|
||||||
|
add=anchor + '''
|
||||||
|
elif isinstance(api_video_workflow, str) and api_video_workflow.startswith(\"comfy/\"):
|
||||||
|
api_video_params[\"audio_path\"] = frame.audio_path'''
|
||||||
|
if 'api_video_workflow.startswith(\"comfy/\")' in src:
|
||||||
|
print('already patched')
|
||||||
|
else:
|
||||||
|
src=src.replace(anchor, add, 1)
|
||||||
|
open(p,'w').write(src)
|
||||||
|
print('patched')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `patched`(或 `already patched`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 语法校验 + 重启 + 备份**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/pipelines/asset_based.py').read()); print('syntax OK')"
|
||||||
|
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||||
|
mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/pipelines/asset_based.py /opt/Pixelle-Video/patches/asset_based.py.patched
|
||||||
|
```
|
||||||
|
Expected: `syntax OK`;容器 healthy;备份文件存在。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 确认改动重启后仍在**
|
||||||
|
|
||||||
|
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -c 'api_video_workflow.startswith(\"comfy/\")' /app/pixelle_video/pipelines/asset_based.py"`
|
||||||
|
Expected: `1`。无 git commit(Pixelle 非本仓库)。
|
||||||
|
|
||||||
|
<!-- T4 -->
|
||||||
|
|
||||||
|
## Task 4: 部署 BoosAPI + 端到端联调
|
||||||
|
|
||||||
|
**Files:** 无(部署 + 运行时验证)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重新构建并重启 BoosAPI(含前端改动)**
|
||||||
|
|
||||||
|
前端改了需重新 build 前端资源,后端改了需重新编译。用项目脚本:
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
> 若只想快速验证后端逻辑、前端单独 build,可 `npm run build:web` + `./build-and-run.sh --backend-only`。
|
||||||
|
> Expected: 构建成功,BoosAPI 起在 :4000。注意这是动线上服务,确认当前无关键流量再重启。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 确认数字人 UI 出现「视频工作流」选择器**
|
||||||
|
|
||||||
|
打开管理控制台数字人页面,确认新增「视频工作流(可选)」下拉,选项含
|
||||||
|
`video_digital_ltx2`(comfy)+ `video_wan2.1_fusionx`(comfy)等 video_* 工作流。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 发起数字人任务,视频工作流选 `comfy/video_digital_ltx2.json`**
|
||||||
|
|
||||||
|
给肖像图 + 文本 + 选 TTS,视频工作流选 `comfy/video_digital_ltx2.json`,提交。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 监控流转(BoosAPI 日志 + Comfy Cloud job)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
Expected: 新日志(id 增长),数字人任务推进;Comfy Cloud 出现 LTX2.3 job;最终出说话视频。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 出错归因**
|
||||||
|
|
||||||
|
- LoadImage 仍用默认图 → 查 media.py image_path 转发(Task 已完成,确认 `grep image_path /app/pixelle_video/services/media.py`)。
|
||||||
|
- LoadAudio 无音频 → 查 Task 3 注入(`grep comfy/ /app/pixelle_video/pipelines/asset_based.py`)+ workflow 标记 `$~audio_path!`。
|
||||||
|
- `api_video_workflow` 没传到 → 查 Task 2 后端转发 + Task 1 前端 payload 字段名(`api_video_workflow`)。
|
||||||
|
- 节点/模型错 → 不应发生(probe 已过);若发生看 Comfy Cloud 真实回包。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 成功标准**
|
||||||
|
|
||||||
|
数字人任务选 comfy/video_digital_ltx2.json 后,Comfy Cloud 出真实 job,注入了
|
||||||
|
image_path+audio_path+prompt,返回带音频说话视频,数字人成片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 前端:数字人页有「视频工作流」可选下拉,列 video_* 工作流,typecheck 干净。
|
||||||
|
- 后端:`api_video_workflow` 透传给 Pixelle,`cargo build -j 2` 通过。
|
||||||
|
- Pixelle:comfy 视频工作流注入 audio_path,重启生效,备份 patches/。
|
||||||
|
- 端到端:选 comfy/video_digital_ltx2.json 出说话视频。
|
||||||
|
- 向后兼容:不选视频工作流时,现有「图片+HTML 合成」行为不变。
|
||||||
|
|
||||||
|
## 已知风险 / 备注
|
||||||
|
|
||||||
|
- Pixelle asset_based.py 改动在镜像内置层,重建镜像会丢,已备份 patches/,长期应固化 Dockerfile。
|
||||||
|
- Task 4 动线上服务(重启 BoosAPI),确认时机。
|
||||||
|
- 单人肖像;多人不在范围。
|
||||||
|
- LTX2.3 执行时长/分辨率等真实生成参数,若出片质量/超时问题属调参,非本计划的接线问题。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# LTX2.3 数字人工作流(Comfy Cloud)实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 Pixelle 数字人(说话头像)走 Comfy Cloud:把官方 LTX2.3 id_lora 模板做成 `comfy/digital_ltx2.json` 工作流,补上 Pixelle media.py 的 image_path 转发缺口,经已建好的 comfy-cloud 适配器执行。
|
||||||
|
|
||||||
|
**Architecture:** 工作流文件落 Pixelle 宿主 `data/workflows/comfy/`(经 `./data` 挂载),用 comfykit 的 `_meta.title` 标记(`$~image_path!`/`$~audio_path!`/`$prompt!`)注入肖像/音频/脚本;media.py 非 api 分支补 image_path 进 workflow_params;comfy/ 通道经 `comfyui_url`(=BoosAPI `/comfy-cloud` 适配器)打到 Comfy Cloud。
|
||||||
|
|
||||||
|
**Tech Stack:** Python (Pixelle/comfykit), ComfyUI API-format JSON, Comfy Cloud API, Docker。
|
||||||
|
|
||||||
|
> **环境约定:** Pixelle 跑在 Docker 容器 `pixelle-video-api`,代码 `/app/pixelle_video/`,工作流 `/app/data/workflows/`(宿主 `/opt/Pixelle-Video/data/workflows/`,bind mount,改宿主即生效)。容器内 python:`/app/.venv/bin/python`。BoosAPI 适配端点:容器内经 `http://172.17.0.1:4000/comfy-cloud`,宿主经 `http://127.0.0.1:4000/comfy-cloud`。改 media.py 后需 `docker compose restart api`。
|
||||||
|
|
||||||
|
> **硬依赖(Task 1 阻塞全局):** 需用户从 Comfy Cloud 网页导出 `video_ltx2_3_id_lora` 的 **API 格式** JSON 到 `/tmp/ltx2_api.json`。服务端无法代取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
- 用户提供:`/tmp/ltx2_api.json` —— Comfy Cloud 导出的 API 格式工作流(子图已展开)。
|
||||||
|
- Create `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json` —— 改造后的数字人工作流(加标记、删 RecordAudio)。
|
||||||
|
- Modify(容器内)`/app/pixelle_video/services/media.py` —— 非 api 分支把 image_path 加入 workflow_params;同步宿主持久化方案。
|
||||||
|
- 临时:`/tmp/digital_ltx2_probe.json` —— 校验探测用 payload。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TASKS -->
|
||||||
|
|
||||||
|
## Task 1: 获取并校验 API 格式导出(硬依赖)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 用户提供:`/tmp/ltx2_api.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 确认导出文件存在且是 API 格式**
|
||||||
|
|
||||||
|
用户在 Comfy Cloud 网页打开 `video_ltx2_3_id_lora`(或 share `71f221d28df8`)→ Workflow → Export (API Format) → 放到 `/tmp/ltx2_api.json`。
|
||||||
|
|
||||||
|
Run: `rtk proxy docker exec pixelle-video-api sh -lc "ls -la /tmp/ltx2_api.json 2>/dev/null || echo MISSING"`
|
||||||
|
注意:文件在宿主还是容器?用户可能放宿主 `/tmp`。两处都查:
|
||||||
|
Run(宿主): `ls -la /tmp/ltx2_api.json 2>/dev/null || echo HOST_MISSING`
|
||||||
|
Expected: 文件存在(任一处)。若两处都 MISSING → BLOCKED,等用户提供。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 校验是 API 格式(不是 UI 格式)**
|
||||||
|
|
||||||
|
API 格式是 `{节点id: {class_type, inputs, ...}}`,无顶层 `nodes` 数组。
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||||
|
is_ui = isinstance(wf,dict) and 'nodes' in wf
|
||||||
|
print('UI格式(需重导)' if is_ui else 'API格式 OK')
|
||||||
|
if not is_ui:
|
||||||
|
print('节点数:', len(wf))
|
||||||
|
has_sub = any('subgraph' in str(v.get('class_type','')).lower() for v in wf.values() if isinstance(v,dict))
|
||||||
|
print('含未展开子图?', has_sub)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `API格式 OK`,节点数 >10,无未展开子图。若是 UI 格式或含子图 → 让用户用 **API Format** 重新导出(普通 Export 是 UI 格式)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 列出节点类型 + 模型引用,核对 Comfy Cloud 兼容**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||||
|
from collections import Counter
|
||||||
|
c=Counter(v.get('class_type') for v in wf.values() if isinstance(v,dict))
|
||||||
|
print('节点类型:')
|
||||||
|
for t,n in c.most_common(): print(f' {n}x {t}')
|
||||||
|
print('--- 模型引用 ---')
|
||||||
|
for nid,nd in wf.items():
|
||||||
|
if not isinstance(nd,dict): continue
|
||||||
|
for k,v in (nd.get('inputs') or {}).items():
|
||||||
|
if isinstance(v,str) and v.endswith('.safetensors'):
|
||||||
|
print(f' {nid}.{k} = {v}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: 看到 LTX2.3 节点 + 模型文件名。记录下来,Task 2 核对它们在 Comfy Cloud 池里(之前已验证 ltx-2.3-* 系列在;若导出引用了不同文件名,需在此对照 `/tmp/cc_nodes.json` 的池子)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 无需提交(纯校验,无文件改动)**
|
||||||
|
|
||||||
|
本任务只确认输入就绪。无 commit。
|
||||||
|
|
||||||
|
<!-- T2 -->
|
||||||
|
|
||||||
|
## Task 2: 改造工作流(加标记 + 删 RecordAudio + 落 comfy/)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 找到要标记的节点 id(LoadImage/LoadAudio/文本/RecordAudio)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||||
|
for nid,nd in wf.items():
|
||||||
|
if not isinstance(nd,dict): continue
|
||||||
|
ct=nd.get('class_type','')
|
||||||
|
if ct in ('LoadImage','LoadAudio','RecordAudio','SaveVideo','PrimitiveStringMultiline','CLIPTextEncode'):
|
||||||
|
print(nid, ct, '| inputs:', json.dumps(nd.get('inputs',{}),ensure_ascii=False)[:100])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: 看到 LoadImage、LoadAudio、RecordAudio、SaveVideo、文本节点各自的 id。记下:
|
||||||
|
- LoadImage 的 id(标 `$~image_path!`)
|
||||||
|
- LoadAudio 的 id(标 `$~audio_path!`)
|
||||||
|
- 文本提示节点 id(标 `$prompt!`)—— 优先 `PrimitiveStringMultiline`;若文本经 CLIPTextEncode 的 `text` 字段直接给,则标 CLIPTextEncode
|
||||||
|
- RecordAudio 的 id(删除)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写改造脚本生成 comfy/digital_ltx2.json**
|
||||||
|
|
||||||
|
> 把下面 `IMAGE_ID`/`AUDIO_ID`/`TEXT_ID`/`RECORD_ID` 替换为 Step 1 查到的实际 id。
|
||||||
|
> 文本节点字段名:若 PrimitiveStringMultiline 用 `value` 字段,标记写 `$prompt.value!`;
|
||||||
|
> 若 CLIPTextEncode 用 `text` 字段,写 `$prompt.text!`。按 Step 1 实际字段定。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||||
|
|
||||||
|
IMAGE_ID='REPLACE_IMAGE_ID'
|
||||||
|
AUDIO_ID='REPLACE_AUDIO_ID'
|
||||||
|
TEXT_ID='REPLACE_TEXT_ID'
|
||||||
|
TEXT_MARKER='\$prompt.value!' # 或 \$prompt.text! 视字段而定
|
||||||
|
RECORD_ID='REPLACE_RECORD_ID' # 若无 RecordAudio 留空字符串
|
||||||
|
|
||||||
|
def set_title(nid, marker):
|
||||||
|
nd=wf[nid]
|
||||||
|
nd.setdefault('_meta',{})['title']=marker
|
||||||
|
|
||||||
|
set_title(IMAGE_ID, '\$~image_path!')
|
||||||
|
set_title(AUDIO_ID, '\$~audio_path!')
|
||||||
|
set_title(TEXT_ID, TEXT_MARKER)
|
||||||
|
|
||||||
|
# 删 RecordAudio 并清理引用它的连线(把指向它的输入置空/删除)
|
||||||
|
if RECORD_ID and RECORD_ID in wf:
|
||||||
|
wf.pop(RECORD_ID)
|
||||||
|
for nid,nd in wf.items():
|
||||||
|
if not isinstance(nd,dict): continue
|
||||||
|
for k,v in list((nd.get('inputs') or {}).items()):
|
||||||
|
if isinstance(v,list) and len(v)==2 and str(v[0])==RECORD_ID:
|
||||||
|
# 该输入原本接 RecordAudio;数字人用 LoadAudio,删除这个悬空连接
|
||||||
|
del nd['inputs'][k]
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs('/opt/Pixelle-Video/data/workflows/comfy', exist_ok=True)
|
||||||
|
json.dump(wf, open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json','w'), ensure_ascii=False, indent=2)
|
||||||
|
print('written, 节点数:', len(wf))
|
||||||
|
# 校验标记落位
|
||||||
|
for nid in [IMAGE_ID,AUDIO_ID,TEXT_ID]:
|
||||||
|
print(nid, '->', wf.get(nid,{}).get('_meta',{}).get('title'))
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: 写入成功,三个标记落位正确,RecordAudio 已删。
|
||||||
|
|
||||||
|
> ⚠️ RecordAudio 与 LoadAudio 的关系:数字人用 LoadAudio 输入旁白。若导出的图里音频源是 RecordAudio 而非 LoadAudio(录音节点直接接到音频处理),则不能简单删 RecordAudio——要把音频处理节点的输入改接到 LoadAudio。Step 1 查清音频实际从哪个节点流出;若是 RecordAudio 流出,本步改为:保留一个 LoadAudio(标 `$~audio_path!`)并把下游音频输入接到 LoadAudio 的输出。具体连接以 Step 1 的图为准。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 校验生成的 JSON 合法 + comfykit 能解析出参数**
|
||||||
|
|
||||||
|
Run(容器内,用 comfykit 解析):
|
||||||
|
```bash
|
||||||
|
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||||
|
import json
|
||||||
|
from comfykit.comfyui.workflow_parser import WorkflowParser
|
||||||
|
wf=json.load(open('/app/data/workflows/comfy/digital_ltx2.json'))
|
||||||
|
p=WorkflowParser()
|
||||||
|
params=[]
|
||||||
|
for nid,nd in wf.items():
|
||||||
|
if not isinstance(nd,dict): continue
|
||||||
|
pl,ml,om=p.parse_node(nid,nd)
|
||||||
|
for x in pl: params.append((x.name,x.required,x.need_upload))
|
||||||
|
print('解析出的参数:', params)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: 看到 `image_path`(upload=True)、`audio_path`(upload=True)、`prompt` 三个参数。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit(工作流文件在 Pixelle 宿主目录,非 BoosAPI 仓库——仅记录到计划备注)**
|
||||||
|
|
||||||
|
工作流文件不在 git 仓库内。无 git commit;在此记录文件已落 `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`。
|
||||||
|
|
||||||
|
<!-- T3 -->
|
||||||
|
|
||||||
|
## Task 3: 补 media.py 转发 image_path 到 comfy 通道
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify(容器内):`/app/pixelle_video/services/media.py`
|
||||||
|
|
||||||
|
> 注:`pixelle_video` 源码**在镜像内置,不在挂载卷**。容器内改 `docker compose restart` 能留,
|
||||||
|
> 但 `docker compose down && up`/重建镜像会丢。持久化方案见 Step 4。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 定位 workflow_params 构建处**
|
||||||
|
|
||||||
|
`media.py` 的 `__call__` 里,api/ 分支之后、`workflow_params = {"prompt": prompt}` 起的那段
|
||||||
|
(约 line 232)。`image_path` 是 `__call__` 的具名参数但没进 `workflow_params`。
|
||||||
|
|
||||||
|
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'workflow_params = {\"prompt\"' /app/pixelle_video/services/media.py"`
|
||||||
|
Expected: 定位到该行号(约 232)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 workflow_params 构建处加入 image_path**
|
||||||
|
|
||||||
|
在 `workflow_params = {"prompt": prompt}` 之后插入(用 sed 或 python 改文件)。注意这是容器内文件,
|
||||||
|
用 `docker exec ... python` 做精确插入最稳妥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||||
|
p='/app/pixelle_video/services/media.py'
|
||||||
|
src=open(p).read()
|
||||||
|
anchor='workflow_params = {\"prompt\": prompt}'
|
||||||
|
assert anchor in src, 'anchor not found'
|
||||||
|
add='''workflow_params = {\"prompt\": prompt}
|
||||||
|
if image_path is not None:
|
||||||
|
workflow_params[\"image_path\"] = image_path'''
|
||||||
|
if 'workflow_params[\"image_path\"]' in src:
|
||||||
|
print('already patched')
|
||||||
|
else:
|
||||||
|
src=src.replace(anchor, add, 1)
|
||||||
|
open(p,'w').write(src)
|
||||||
|
print('patched')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `patched`(或 `already patched`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 校验语法 + 重启容器**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/services/media.py').read()); print('syntax OK')"
|
||||||
|
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||||
|
```
|
||||||
|
Expected: `syntax OK`;容器 `Up ... (healthy)`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 持久化方案(防镜像重建丢失)**
|
||||||
|
|
||||||
|
把改动备份到宿主,并记录到 Pixelle 部署说明:
|
||||||
|
```bash
|
||||||
|
rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched 2>/dev/null || (mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched)
|
||||||
|
echo "已备份改后 media.py 到 /opt/Pixelle-Video/patches/;重建镜像后需重新 docker cp 回 /app/pixelle_video/services/media.py 或固化进 Dockerfile"
|
||||||
|
```
|
||||||
|
Expected: 备份文件存在。无 git commit(Pixelle 非本仓库)。
|
||||||
|
|
||||||
|
<!-- T4 -->
|
||||||
|
|
||||||
|
## Task 4: 校验探测(确认 Comfy Cloud 接受工作流)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 临时:`/tmp/digital_ltx2_probe.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 用占位输入构建 probe payload**
|
||||||
|
|
||||||
|
注入标记的字段需要具体值才能提交校验。用占位文件名探测(校验在执行前,不真正读文件):
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
wf=json.load(open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json'))
|
||||||
|
# probe 直接提交节点图本身(标记字段已有默认值或占位);comfykit 注入是运行时的事,
|
||||||
|
# 这里验证 Comfy Cloud 是否接受这套节点+模型
|
||||||
|
json.dump({'prompt':wf}, open('/tmp/digital_ltx2_probe.json','w'), ensure_ascii=False)
|
||||||
|
print('probe payload bytes:', len(open('/tmp/digital_ltx2_probe.json').read()))
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: payload 生成。
|
||||||
|
|
||||||
|
> 注:工作流里 LoadImage/LoadAudio 的默认 widget 值(模板自带的示例文件名)可能在 Comfy Cloud
|
||||||
|
> 不存在,导致校验报"文件不存在"。这是**预期且可接受**的——它证明节点+模型校验已过,只差真实输入。
|
||||||
|
> 关注点是有没有 `unsupported node type` / 模型缺失类错误。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提交探测,读校验结果**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:4000/comfy-cloud/prompt -H "Content-Type: application/json" --data @/tmp/digital_ltx2_probe.json 2>&1 | head -15
|
||||||
|
```
|
||||||
|
Expected 判读:
|
||||||
|
- `{"node_errors":{},"prompt_id":"..."}` HTTP 200 → 完美,全兼容。
|
||||||
|
- `400 ... unsupported node type 'X'` → 节点 X 不在 Comfy Cloud,需在工作流里替换/移除该节点(回 Task 2 处理)。
|
||||||
|
- `400 ... 文件/模型 不存在`(针对 LoadImage 示例图或某 .safetensors)→ 若是输入文件(LoadImage 默认图)属预期;若是 .safetensors 模型缺失,需换 Comfy Cloud 有的等价模型(对照 `/tmp/cc_nodes.json` 池)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 据结果迭代或放行**
|
||||||
|
|
||||||
|
- 全兼容 → 进 Task 5。
|
||||||
|
- 节点/模型问题 → 回 Task 2 调整工作流(换节点或模型名),重跑 Task 4。不要跳过。
|
||||||
|
|
||||||
|
<!-- T5 -->
|
||||||
|
|
||||||
|
## Task 5: 真实数字人任务联调
|
||||||
|
|
||||||
|
**Files:** 无(运行时验证)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 Pixelle 前端发起数字人任务,视频/动画工作流选 `comfy/digital_ltx2.json`**
|
||||||
|
|
||||||
|
数字人流程的 image→video 动画步(`api_video_workflow` 或对应 UI 项)填/选 `comfy/digital_ltx2.json`。
|
||||||
|
触发一个任务(给个肖像图 + 文本)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 观察任务流转**
|
||||||
|
|
||||||
|
预期:LLM 脚本步 → 生成肖像图(comfy 图片工作流)+ TTS 音频 → digital_ltx2 动画步:
|
||||||
|
注入 image_path/audio_path/prompt → Comfy Cloud 建 job → 轮询 completed → 取回说话视频 → HTML 合成。
|
||||||
|
|
||||||
|
监控 BoosAPI 侧 proxy_logs:
|
||||||
|
```bash
|
||||||
|
rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
Expected: 新日志(id 增长),数字人任务最终 success / 出片。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 出错则按错误类型归因**
|
||||||
|
|
||||||
|
- `unsupported node` / 模型缺失 → 回 Task 2/4。
|
||||||
|
- image_path 未注入(LoadImage 仍用默认图)→ 回 Task 3 确认 media.py 改动生效(`grep image_path /app/.../media.py`)+ Task 2 确认 `$~image_path!` 标记落位。
|
||||||
|
- 执行期失败(Comfy Cloud job failed)→ 真实生成问题(参数/分辨率/时长),据 job 错误调工作流默认值。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 成功标准**
|
||||||
|
|
||||||
|
数字人任务在 Comfy Cloud 出现真实 job、跑完、返回带音频的说话视频,Pixelle 合成出片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- `comfy/digital_ltx2.json` 落地、comfykit 能解析出 image_path/audio_path/prompt 三参数。
|
||||||
|
- media.py 已转发 image_path 到 comfy 通道(并备份到宿主 patches/)。
|
||||||
|
- 校验探测确认 Comfy Cloud 接受工作流(节点+模型兼容)。
|
||||||
|
- 真实数字人任务经 comfy/Comfy Cloud 出片。
|
||||||
|
|
||||||
|
## 已知风险 / 备注
|
||||||
|
|
||||||
|
- 硬依赖用户导出 API JSON(Task 1);拿不到则全卡。
|
||||||
|
- media.py 改动在镜像内置层,重建镜像会丢,已备份到 `/opt/Pixelle-Video/patches/`,长期应固化进 Dockerfile/启动脚本。
|
||||||
|
- 工作流默认 widget 值(示例图/音频文件名)在 Comfy Cloud 不存在属正常,真实运行靠注入覆盖。
|
||||||
|
- 单人肖像;多人对话不在范围。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# Pixelle 连接凭证纳入 BoosAPI 管理 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在 BoosAPI 连接管理页加「Pixelle 连接」卡片,管 runninghub_api_key / comfyui_url / comfyui_api_key 三个凭证,保存即写入 settings 表 + 同步 Pixelle config.yaml + 重启 pixelle 容器生效。
|
||||||
|
|
||||||
|
**Architecture:** 新增 service `pixelle_connection.rs`(读写 settings 表 + 改 config.yaml YAML 三键 + docker restart)+ admin 路由(GET/PUT,密钥掩码)+ 前端 Accounts 页卡片。settings 表为真相源,config.yaml 是同步目标。
|
||||||
|
|
||||||
|
**Tech Stack:** Rust(axum, sqlx, serde_yaml<新增依赖>, tokio::process)、React/TS。
|
||||||
|
|
||||||
|
> **构建/测试:** `cargo build -j 2`(-j2 防 OOM)、`cargo test -p boosapi --lib pixelle_connection`、`npm run typecheck:web`。分支 `feat/comfy-cloud-adapter`。Pixelle compose 目录 `/opt/Pixelle-Video`,config `/opt/Pixelle-Video/config.yaml`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
- Modify `Cargo.toml` — 加 `serde_yaml` 依赖。
|
||||||
|
- Modify `src/config.rs` — `PixelleVideoConfig` 加 `config_path` / `compose_dir` 两字段(默认 `/opt/Pixelle-Video/config.yaml`、`/opt/Pixelle-Video`)。
|
||||||
|
- Create `src/services/pixelle_connection.rs` — read/save settings + sync_to_pixelle(改 yaml)+ restart。
|
||||||
|
- Modify `src/services/mod.rs` — `pub mod pixelle_connection;`。
|
||||||
|
- Create `src/routes/admin/pixelle_connection.rs` — GET/PUT handler(掩码)。
|
||||||
|
- Modify `src/routes/admin/mod.rs` — 声明子模块 + 挂 2 条路由。
|
||||||
|
- Modify `src/web/api.ts` — adminApi 加 getPixelleConnection / savePixelleConnection。
|
||||||
|
- Modify `src/web/pages/Accounts.tsx` — 加「Pixelle 连接」卡片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TASKS -->
|
||||||
|
|
||||||
|
## Task 1: 加 serde_yaml 依赖 + config 路径字段
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Cargo.toml`
|
||||||
|
- Modify: `src/config.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Cargo.toml 加 serde_yaml**
|
||||||
|
|
||||||
|
在 `[dependencies]` 段加(版本对齐项目 serde 生态,用 0.9):
|
||||||
|
```toml
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)**
|
||||||
|
|
||||||
|
在 `pub timeout_secs: u64,` 之后加:
|
||||||
|
```rust
|
||||||
|
#[serde(default = "default_pixelle_config_path")]
|
||||||
|
pub config_path: String,
|
||||||
|
#[serde(default = "default_pixelle_compose_dir")]
|
||||||
|
pub compose_dir: String,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 加默认值函数(src/config.rs,default_pixelle_backend_provider 附近)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn default_pixelle_config_path() -> String {
|
||||||
|
"/opt/Pixelle-Video/config.yaml".to_string()
|
||||||
|
}
|
||||||
|
fn default_pixelle_compose_dir() -> String {
|
||||||
|
"/opt/Pixelle-Video".to_string()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: PixelleVideoConfig::default() 补两字段**
|
||||||
|
|
||||||
|
找到 `impl Default for ...` 或 `PixelleVideoConfig::default()`(约 line 207 区域,有 `api_base_url: Some(...)` 那个),在其字段列表末尾加:
|
||||||
|
```rust
|
||||||
|
config_path: default_pixelle_config_path(),
|
||||||
|
compose_dir: default_pixelle_compose_dir(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 构建确认**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||||
|
Expected: `Finished`。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add Cargo.toml Cargo.lock src/config.rs
|
||||||
|
git commit -m "feat(pixelle): 加 serde_yaml + Pixelle config 路径配置项
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T2 -->
|
||||||
|
|
||||||
|
## Task 2: pixelle_connection service(settings + yaml 同步)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/pixelle_connection.rs`
|
||||||
|
- Modify: `src/services/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: src/services/mod.rs 加模块声明**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod pixelle_connection;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写失败测试 `src/services/pixelle_connection.rs`(含结构 + sync 测试)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PixelleConnection {
|
||||||
|
pub runninghub_api_key: Option<String>,
|
||||||
|
pub comfyui_url: Option<String>,
|
||||||
|
pub comfyui_api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_RH: &str = "pixelle.runninghub_api_key";
|
||||||
|
const KEY_URL: &str = "pixelle.comfyui_url";
|
||||||
|
const KEY_CK: &str = "pixelle.comfyui_api_key";
|
||||||
|
|
||||||
|
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
|
||||||
|
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.bind(k).fetch_optional(db).await?)
|
||||||
|
}
|
||||||
|
Ok(PixelleConnection {
|
||||||
|
runninghub_api_key: get(db, KEY_RH).await?,
|
||||||
|
comfyui_url: get(db, KEY_URL).await?,
|
||||||
|
comfyui_api_key: get(db, KEY_CK).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
|
||||||
|
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
|
||||||
|
if let Some(val) = v {
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(k).bind(val).execute(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
put(db, KEY_RH, &conn.runninghub_api_key).await?;
|
||||||
|
put(db, KEY_URL, &conn.comfyui_url).await?;
|
||||||
|
put(db, KEY_CK, &conn.comfyui_api_key).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
|
||||||
|
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
|
||||||
|
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
|
||||||
|
let content = std::fs::read_to_string(config_path)
|
||||||
|
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
|
||||||
|
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
|
||||||
|
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
|
||||||
|
let comfyui = doc.get_mut("comfyui")
|
||||||
|
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
|
||||||
|
let map = comfyui.as_mapping_mut()
|
||||||
|
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
|
||||||
|
if let Some(v) = &conn.runninghub_api_key {
|
||||||
|
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
if let Some(v) = &conn.comfyui_url {
|
||||||
|
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
if let Some(v) = &conn.comfyui_api_key {
|
||||||
|
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||||
|
let backup = format!("{config_path}.bak-{ts}");
|
||||||
|
std::fs::copy(config_path, &backup)
|
||||||
|
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
|
||||||
|
let out = serde_yaml::to_string(&doc)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||||
|
std::fs::write(config_path, out)
|
||||||
|
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_updates_three_keys_preserves_rest() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("config.yaml");
|
||||||
|
std::fs::write(&path,
|
||||||
|
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
|
||||||
|
let conn = PixelleConnection {
|
||||||
|
runninghub_api_key: Some("newkey".into()),
|
||||||
|
comfyui_url: Some("http://new".into()),
|
||||||
|
comfyui_api_key: Some("newck".into()),
|
||||||
|
};
|
||||||
|
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
|
||||||
|
assert!(std::path::Path::new(&backup).exists());
|
||||||
|
let after = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(after.contains("newkey"));
|
||||||
|
assert!(after.contains("http://new"));
|
||||||
|
assert!(after.contains("newck"));
|
||||||
|
// 保留其余键
|
||||||
|
assert!(after.contains("runninghub_concurrent_limit"));
|
||||||
|
assert!(after.contains("project_name"));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行测试,确认失败**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -15`
|
||||||
|
Expected: FAIL —— 模块/函数未定义(首次编译错误)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 实现**
|
||||||
|
|
||||||
|
Step 2 的代码即实现(测试与实现同文件)。确保 mod.rs 已声明。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行测试,确认通过**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -10`
|
||||||
|
Expected: PASS(1 个测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/services/mod.rs src/services/pixelle_connection.rs
|
||||||
|
git commit -m "feat(pixelle): 连接凭证 service(settings 读写 + config.yaml 同步)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T3 -->
|
||||||
|
|
||||||
|
## Task 3: restart helper + admin 路由(掩码)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/pixelle_connection.rs`(加 restart)
|
||||||
|
- Create: `src/routes/admin/pixelle_connection.rs`
|
||||||
|
- Modify: `src/routes/admin/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 加 restart_pixelle(pixelle_connection.rs,sync_to_pixelle 之后)**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
|
||||||
|
let output = tokio::process::Command::new("docker")
|
||||||
|
.args(["compose", "restart", "api"])
|
||||||
|
.current_dir(compose_dir)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AppError::ServiceUnavailable(format!(
|
||||||
|
"Pixelle 重启失败: {}", stderr.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写 admin 路由 `src/routes/admin/pixelle_connection.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::services::pixelle_connection::{self, PixelleConnection};
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const MASK_SENTINEL: &str = "••••";
|
||||||
|
|
||||||
|
fn mask(v: &Option<String>) -> Value {
|
||||||
|
match v {
|
||||||
|
Some(s) if s.len() > 4 => json!(format!("{}{}", MASK_SENTINEL, &s[s.len()-4..])),
|
||||||
|
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
|
||||||
|
_ => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
let c = pixelle_connection::read_connection(&state.db).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"connection": {
|
||||||
|
// url 不算敏感,明文回;两个 key 掩码
|
||||||
|
"comfyuiUrl": c.comfyui_url,
|
||||||
|
"runninghubApiKey": mask(&c.runninghub_api_key),
|
||||||
|
"comfyuiApiKey": mask(&c.comfyui_api_key),
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdatePayload {
|
||||||
|
runninghub_api_key: Option<String>,
|
||||||
|
comfyui_url: Option<String>,
|
||||||
|
comfyui_api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
|
||||||
|
// 掩码值(以 •••• 开头)表示"未修改",跳过
|
||||||
|
match incoming {
|
||||||
|
Some(s) if s.starts_with(MASK_SENTINEL) => None,
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_connection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(p): Json<UpdatePayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let conn = PixelleConnection {
|
||||||
|
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
|
||||||
|
comfyui_url: p.comfyui_url, // url 明文,不掩码
|
||||||
|
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
|
||||||
|
};
|
||||||
|
pixelle_connection::save_connection(&state.db, &conn).await?;
|
||||||
|
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
|
||||||
|
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||||
|
match restart {
|
||||||
|
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
|
||||||
|
Err(e) => Ok(Json(json!({
|
||||||
|
"success": true, "backup": backup, "restarted": false,
|
||||||
|
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
|
||||||
|
}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: src/routes/admin/mod.rs 声明子模块 + 挂路由**
|
||||||
|
|
||||||
|
模块声明区(`mod users;` 附近)加:
|
||||||
|
```rust
|
||||||
|
mod pixelle_connection;
|
||||||
|
```
|
||||||
|
在 `routes()` 的路由链里(`/api/admin/user-balances` 那一带)加:
|
||||||
|
```rust
|
||||||
|
.route(
|
||||||
|
"/api/admin/pixelle/connection",
|
||||||
|
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
确认 `get`/`put` 已从 axum::routing 引入(文件顶部应已 `use axum::routing::{get, post, ...}`;若缺 put 则补)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 构建确认**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||||
|
Expected: `Finished`。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/services/pixelle_connection.rs src/routes/admin/pixelle_connection.rs src/routes/admin/mod.rs
|
||||||
|
git commit -m "feat(pixelle): 连接凭证 admin 端点(GET/PUT 掩码 + 重启)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T4 -->
|
||||||
|
|
||||||
|
## Task 4: 前端连接管理卡片
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/api.ts`
|
||||||
|
- Modify: `src/web/pages/Accounts.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: api.ts adminApi 加方法**
|
||||||
|
|
||||||
|
在 adminApi 对象里(找 `request(` 范式的 admin 方法群)加:
|
||||||
|
```typescript
|
||||||
|
getPixelleConnection: () =>
|
||||||
|
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
|
||||||
|
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
|
||||||
|
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Accounts.tsx 加「Pixelle 连接」卡片**
|
||||||
|
|
||||||
|
先读 `src/web/pages/Accounts.tsx` 顶部,确认 `api`/`adminApi` 的导入名和 toast 用法。在页面主体某处(如页尾)加一个卡片组件块。最小实现(state + 加载 + 保存):
|
||||||
|
```tsx
|
||||||
|
// —— Pixelle 连接(加在组件内 state 区)——
|
||||||
|
const [pxConn, setPxConn] = useState({ comfyuiUrl: '', runninghubApiKey: '', comfyuiApiKey: '' });
|
||||||
|
const [pxSaving, setPxSaving] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getPixelleConnection()
|
||||||
|
.then((r) => setPxConn({
|
||||||
|
comfyuiUrl: r.connection?.comfyuiUrl || '',
|
||||||
|
runninghubApiKey: r.connection?.runninghubApiKey || '',
|
||||||
|
comfyuiApiKey: r.connection?.comfyuiApiKey || '',
|
||||||
|
}))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const savePxConn = async () => {
|
||||||
|
setPxSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await adminApi.savePixelleConnection({
|
||||||
|
runninghubApiKey: pxConn.runninghubApiKey || undefined,
|
||||||
|
comfyuiUrl: pxConn.comfyuiUrl || undefined,
|
||||||
|
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
|
||||||
|
});
|
||||||
|
if (res.warning) toast.error(res.warning);
|
||||||
|
else toast.success('已保存并重启 Pixelle,约数秒生效');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setPxSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
卡片 JSX(加到 return 的合适位置,样式对齐页面现有 card):
|
||||||
|
```tsx
|
||||||
|
<div className="card" style={{ marginTop: 16, padding: 16 }}>
|
||||||
|
<h3>Pixelle 连接</h3>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<span>ComfyUI URL</span>
|
||||||
|
<input value={pxConn.comfyuiUrl} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))} placeholder="http://172.17.0.1:4000/comfy-cloud" style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<span>RunningHub API Key</span>
|
||||||
|
<input value={pxConn.runninghubApiKey} onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<span>Comfy Cloud API Key</span>
|
||||||
|
<input value={pxConn.comfyuiApiKey} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
|
||||||
|
{pxSaving ? '保存中...' : '保存并重启 Pixelle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
> 注:`useState`/`useEffect` 若未导入需补;`adminApi`/`api` 用 Accounts.tsx 实际的导入名(读文件确认是 `api` 还是 `adminApi`)。若提交掩码值(`••••...`)后端会跳过不改,符合预期。
|
||||||
|
|
||||||
|
- [ ] **Step 3: typecheck**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
|
||||||
|
Expected: 无 TS 错误。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx
|
||||||
|
git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T5 -->
|
||||||
|
|
||||||
|
## Task 5: 部署 + 端到端验证
|
||||||
|
|
||||||
|
**Files:** 无(部署 + 运行时)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重建并重启 BoosAPI(含前后端改动)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
Expected: 构建成功,BoosAPI 起在 :4000。动线上服务,确认时机。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 连接管理页确认「Pixelle 连接」卡片**
|
||||||
|
|
||||||
|
硬刷新管理控制台连接管理页(`/accounts`),确认出现「Pixelle 连接」卡片,
|
||||||
|
ComfyUI URL 显示当前值,两个 key 显示掩码(`••••xxxx`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改一个值测试同步**
|
||||||
|
|
||||||
|
把 RunningHub API Key 改成一个新值,保存。确认:
|
||||||
|
```bash
|
||||||
|
grep runninghub_api_key /opt/Pixelle-Video/config.yaml | sed 's/[a-f0-9]\{20\}/.../'
|
||||||
|
ls -t /opt/Pixelle-Video/config.yaml.bak-* | head -1
|
||||||
|
rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||||
|
```
|
||||||
|
Expected: config.yaml 的 key 变成新值;有新备份文件;容器重启后 healthy。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 掩码不改测试**
|
||||||
|
|
||||||
|
只改 ComfyUI URL、两个 key 保持掩码原值,保存。确认 config.yaml 里两个 key **没变**
|
||||||
|
(掩码值被后端跳过),URL 变了。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 成功标准**
|
||||||
|
|
||||||
|
UI 改 Pixelle 凭证 → settings 表存值 + config.yaml 同步 + 容器重启,不再需要手动编辑文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 后端:pixelle_connection service(读写 settings + sync_to_pixelle + restart)+ admin GET/PUT 端点,`cargo build -j 2` 通过,service 单测通过。
|
||||||
|
- 前端:连接管理页有「Pixelle 连接」卡片,typecheck 干净。
|
||||||
|
- 端到端:UI 改凭证 → config.yaml 同步 + 容器重启生效;掩码值不误改。
|
||||||
|
- 向后兼容:不改 Pixelle 源码;config.yaml 其余键保留;每次改前备份。
|
||||||
|
|
||||||
|
## 已知风险 / 备注
|
||||||
|
|
||||||
|
- serde_yaml 重序列化会丢 config.yaml 注释(已接受;每次有时间戳备份兜底)。
|
||||||
|
- BoosAPI 进程需有 docker compose 执行权限 + config.yaml 读写权限(同宿主 root 运行,满足)。
|
||||||
|
- Task 5 重启 BoosAPI 动线上服务,确认时机。
|
||||||
|
- 备份文件会累积(config.yaml.bak-*),长期可加清理,本期不做。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# workflow_ui.rs 拆分(8 子模块)实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 把 `src/routes/workflow_ui.rs`(6181 行)按职责拆成 `src/routes/workflow_ui/` 目录 8 个子模块,纯机械移动、零逻辑改动,对外接口不变。
|
||||||
|
|
||||||
|
**Architecture:** 先建目录把原文件变 `mod.rs`,再逐个子模块把相关函数剪切出去(`pub(super)` 可见性 + 补 use),每步 cargo 编译验证。Rust 编译器是主安全网。
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, sqlx)。验证:`cargo build -j 2`、`cargo test -p boosapi --lib routes::workflow_ui`。
|
||||||
|
|
||||||
|
> **核心纪律:** 纯搬运,函数体一字不改;只改可见性 + use 路径。不优化、不改逻辑、不动路由路径。每抽完一组即编译+测试+提交。分支 `feat/comfy-cloud-adapter`。
|
||||||
|
|
||||||
|
> **验证基线(开工前先记录):** 跑 `cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"` 记下当前测试数(应 5 个),全程不得减少。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构(目标)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/routes/workflow_ui/
|
||||||
|
mod.rs — routes() + 共享类型(WorkflowCaller/各 Query/Payload struct)+ 跨模块小工具 + 子模块声明 + 测试入口
|
||||||
|
assets.rs — UI 静态资源服务/嵌入目录/index/static/serve_file/官方模板资源
|
||||||
|
auth.rs — caller 解析
|
||||||
|
node_defs.rs — 节点定义 + 工作流模板图
|
||||||
|
models.rs — 模型分类 + folder 模型
|
||||||
|
comfy_proxy.rs — ComfyUI API 代理 handler(settings/userdata/object_info/...)
|
||||||
|
validate.rs — 工作流校验
|
||||||
|
queue.rs — 队列/历史/prompt/view
|
||||||
|
```
|
||||||
|
|
||||||
|
> 当前 `src/routes/workflow_ui.rs`(单文件)→ 变为 `src/routes/workflow_ui/mod.rs` + 7 个兄弟文件。
|
||||||
|
> `src/routes/mod.rs` 的 `mod workflow_ui;` 声明无需改(目录模块同名,Rust 自动找 mod.rs)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TASKS -->
|
||||||
|
|
||||||
|
## Task 0: 文件转目录模块(零拆分,先搭壳)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Move: `src/routes/workflow_ui.rs` → `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 记录验证基线**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||||
|
Expected: 记下测试数(预期 `5 passed`)。后续每步此数不得减少。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 把文件移成目录模块**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi
|
||||||
|
mkdir -p src/routes/workflow_ui
|
||||||
|
git mv src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译 + 测试验证(内容没动,应原样通过)**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||||
|
Expected: `Finished`;测试数与基线一致。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
|
||||||
|
git commit -m "refactor(workflow_ui): 文件转目录模块(mod.rs,内容不变)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T1 -->
|
||||||
|
|
||||||
|
## Task 1: 抽 auth.rs(最小、最低风险,先验证流程)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/routes/workflow_ui/auth.rs`
|
||||||
|
- Modify: `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数(用 grep 定位,函数体一字不改):`caller_from_headers`、`auth_caller`、`caller_user_id`、`caller_is_admin`。它们依赖类型 `WorkflowCaller`(留在 mod.rs)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 mod.rs 顶部加子模块声明**
|
||||||
|
|
||||||
|
在 mod.rs 的 `use` 区之后(`pub fn routes()` 之前)加:
|
||||||
|
```rust
|
||||||
|
mod auth;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 确认这 4 个函数的精确范围 + WorkflowCaller 可见性**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && rtk proxy grep -nE "fn caller_from_headers|fn auth_caller|fn caller_user_id|fn caller_is_admin|enum WorkflowCaller" src/routes/workflow_ui/mod.rs`
|
||||||
|
记录每个函数起止行。`WorkflowCaller` 留 mod.rs,改成 `pub(super) enum WorkflowCaller`(让子模块可用)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 新建 auth.rs,粘入 4 个函数(函数体不改,补 use + 改可见性)**
|
||||||
|
|
||||||
|
新建 `src/routes/workflow_ui/auth.rs`,结构:
|
||||||
|
```rust
|
||||||
|
use super::WorkflowCaller;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
// (按 4 个函数实际用到的依赖补齐 use:validate_jwt、find_downstream_key_by_token 等——
|
||||||
|
// 从 mod.rs 顶部现有 use 里照抄它们需要的那几条)
|
||||||
|
|
||||||
|
pub(super) fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
|
||||||
|
// ←原函数体一字不改粘进来
|
||||||
|
}
|
||||||
|
pub(super) fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
|
||||||
|
// ←原体
|
||||||
|
}
|
||||||
|
pub(super) fn caller_is_admin(caller: &WorkflowCaller) -> bool {
|
||||||
|
// ←原体
|
||||||
|
}
|
||||||
|
pub(super) async fn auth_caller(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
|
||||||
|
// ←原体
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> 关键:这 4 个函数从 mod.rs **删除**;其在 mod.rs/其他子模块的调用点改成 `auth::caller_from_headers(...)` 等(mod.rs 内加 `use auth::{caller_from_headers, auth_caller, caller_user_id, caller_is_admin};` 即可不改调用点写法)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: mod.rs 加 use 引回,删除原 4 函数**
|
||||||
|
|
||||||
|
mod.rs 顶部子模块声明后加:
|
||||||
|
```rust
|
||||||
|
use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
|
||||||
|
```
|
||||||
|
并从 mod.rs 删掉这 4 个函数的原定义。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 编译 + 测试**
|
||||||
|
|
||||||
|
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||||
|
Expected: `Finished`;测试数 == 基线。若编译报「未使用的 use」「私有」「未解析」,按提示补 use / 调可见性(纯机械修),不改逻辑。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/metapi && git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/auth.rs
|
||||||
|
git commit -m "refactor(workflow_ui): 抽出 auth 子模块
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- T2 -->
|
||||||
|
|
||||||
|
## 通用抽取流程(每个子模块任务都按这 6 步)
|
||||||
|
|
||||||
|
每个抽取任务对一组函数执行:
|
||||||
|
1. mod.rs 顶部加 `mod <name>;` 声明。
|
||||||
|
2. grep 定位本组函数精确范围;凡被本组函数引用、但定义留在别处的类型/函数,把那些定义改 `pub(super)`。
|
||||||
|
3. 新建 `<name>.rs`,粘入本组函数(**函数体一字不改**),顶部按实际依赖补 `use`(从 mod.rs 现有 use 照抄需要的;跨模块的用 `use super::...` 或 `use super::<other_mod>::...`),函数可见性改 `pub(super)`。
|
||||||
|
4. 从 mod.rs 删掉这些函数原定义;mod.rs 顶部加 `use <name>::{...};` 把它们引回(调用点写法不变)。
|
||||||
|
5. `cargo build -j 2` + `cargo test -p boosapi --lib routes::workflow_ui`。编译报错按提示机械修(补 use / 调可见性),不改逻辑。测试数不得低于基线。
|
||||||
|
6. 提交:`git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/<name>.rs && git commit -m "refactor(workflow_ui): 抽出 <name> 子模块"`(带 Co-Authored-By)。
|
||||||
|
|
||||||
|
> 注:若某函数同时被多个子模块用,保留在 mod.rs 顶层并 `pub(super)`,各子模块 `use super::该函数`。
|
||||||
|
> 共用小工具(guess_mime_from_path / parse_boolish / safe_view_path / user_home / default_history_limit
|
||||||
|
> / workspace_kind_from_mime)统一留 mod.rs 顶层 `pub(super)`,不下沉到子模块。
|
||||||
|
|
||||||
|
## Task 2: 抽 node_defs.rs(节点定义 + 工作流模板图)
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/node_defs.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数(grep 定位):`known_output_ports`、`core_node_definitions`、`workflow_template_payload`、`core_workflow_template_index`、`inject_boosapi_workflow_template_payload`、`default_boosapi_workflow_graph`、`text_to_video_workflow_graph`、`port_name`、`source_node_type`、`normalize_prompt_payload`、`prompt_link`、`node_type_def_to_comfy`、`input_spec_to_comfy`、`output_specs`、`output_is_list_specs`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `node_defs`。
|
||||||
|
注意:这些函数用到 `WorkflowGraph` / `NodeTypeDefinition` 类型——确认它们的定义位置(可能在 crate::workflow 或 mod.rs),mod.rs 内的留存定义改 `pub(super)`,node_defs.rs 用 `use super::...` 或 `use crate::...` 引入。
|
||||||
|
验证命令:`cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`,期望 Finished + 测试数≥基线。
|
||||||
|
提交 message:`refactor(workflow_ui): 抽出 node_defs 子模块`。
|
||||||
|
|
||||||
|
<!-- T3 -->
|
||||||
|
|
||||||
|
## Task 3: 抽 models.rs(模型分类 + folder 模型)
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/models.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数:`is_exact_workflow_model`、`is_image_workflow_model`、`is_video_workflow_model`、`is_audio_workflow_model`、`is_embedding_workflow_model`、`is_3d_workflow_model`、`is_vision_workflow_model`、`is_chat_workflow_model`、`workflow_route_models`、`image_model_choices`、`workflow_folder_models`、`workflow_folder_is_api_only`、`workflow_folder_has_route_models`、`workflow_folder_route_model_items`、`combo_choices_from_node_input`、`combo_fields_from_node`、`native_folder_models_from_object_info`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `models`。
|
||||||
|
注意:`workflow_route_models` 是 `async fn` 且用 `state.db`,补 `use crate::AppState;` 等。
|
||||||
|
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 models 子模块`。
|
||||||
|
|
||||||
|
<!-- T4 -->
|
||||||
|
|
||||||
|
## Task 4: 抽 assets.rs(UI 静态资源服务)
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/assets.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数:`workflow_ui_root_for_config`、`resolve_workflow_ui_dir`、`embedded_workflow_ui_for_config`、`workflow_ui_dir_is_usable`、`embedded_workflow_ui_is_usable`、`workflow_ui_index_is_placeholder`、`workflow_ui_runtime`、`workflow_ui_build_metadata`、`workflow_cloud_api_key`、`workflow_ui_index`、`workflow_ui_static`、`official_workflow_template_asset`、`serve_file`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `assets`。
|
||||||
|
注意:用到 `include_dir::Dir`、`PathBuf`、`Html`、`Response` 等,按实际补 use。`serve_file` 若被 queue/view 模块也用到,则保留 mod.rs 顶层 `pub(super)`(实现时看引用)。
|
||||||
|
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 assets 子模块`。
|
||||||
|
|
||||||
|
<!-- T5 -->
|
||||||
|
|
||||||
|
## Task 5: 抽 comfy_proxy.rs(ComfyUI API 代理 handler)
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/comfy_proxy.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数:`get_user_config`、`create_user`、`get_settings`、`get_setting`、`store_settings`、`store_setting`、`userdata_list`、`userdata_get`、`userdata_store`、`userdata_delete`、`move_userdata`、`extensions`、`workflow_templates`、`embeddings`、`object_info`、`system_stats`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `comfy_proxy`。
|
||||||
|
注意:这些是路由 handler(多 `async fn ...(State, HeaderMap) -> AppResult<...>`),依赖 `auth_caller`(已在 auth.rs)→ 用 `use super::auth_caller;` 或 `super::auth::auth_caller`。补 axum extract / Json / Response use。
|
||||||
|
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 comfy_proxy 子模块`。
|
||||||
|
|
||||||
|
<!-- T6 -->
|
||||||
|
|
||||||
|
## Task 6: 抽 validate.rs(工作流校验)
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/validate.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数:`validate_workflow`、`validate_workflow_against_object_info`、`validate_workflow_input_file`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `validate`。
|
||||||
|
注意:依赖 node_defs/models 的函数(如 normalize_prompt_payload、combo_fields_from_node)→ 用 `use super::node_defs::...` / `use super::models::...`(确保那些函数已 `pub(super)`)。WorkflowValidateQuery struct 留 mod.rs `pub(super)`。
|
||||||
|
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 validate 子模块`。
|
||||||
|
|
||||||
|
<!-- T7 -->
|
||||||
|
|
||||||
|
## Task 7: 抽 queue.rs(队列/历史/prompt/view)+ 收尾
|
||||||
|
|
||||||
|
**Files:** Create `src/routes/workflow_ui/queue.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||||
|
|
||||||
|
要搬的函数:`queue_status`、`queue`、`queue_mutate`、`history`、`history_mutate`、`history_by_id`、`queue_prompt`、`repair_native_prompt_payload`、`queue_boosapi_prompt`、`interrupt`、`view`、`internal_logs`、`internal_logs_raw`。
|
||||||
|
|
||||||
|
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `queue`。
|
||||||
|
注意:`repair_native_prompt_payload` 很长(2814-3474),依赖 node_defs/models 多个函数;`view` 用 serve_file/safe_view_path。补 `use super::...`。PromptPayload/HistoryQuery/ViewQuery/QueueMutation/UserDataQuery/UploadQuery struct 留 mod.rs `pub(super)`(被多模块用)。
|
||||||
|
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 queue 子模块`。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 最终验收**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/metapi
|
||||||
|
cargo build -j 2 2>&1 | tail -3
|
||||||
|
cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"
|
||||||
|
for f in mod auth node_defs models assets comfy_proxy validate queue; do echo -n "$f.rs: "; wc -l < src/routes/workflow_ui/$f.rs; done
|
||||||
|
```
|
||||||
|
Expected: Finished;测试数≥基线;每个子文件行数显著小于原 6181(mod.rs 应只剩 routes()+共享类型+小工具,目标 <1500 行;无单文件超 ~2000)。
|
||||||
|
|
||||||
|
- [ ] **Step 8: 冒烟(手动)**
|
||||||
|
|
||||||
|
部署后打开工作流 UI、跑一个 prompt,确认行为与拆分前一致(路由路径、handler 行为不变)。
|
||||||
|
|
||||||
|
<!-- END -->
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Comfy Cloud 工作流执行适配器 — 设计
|
||||||
|
|
||||||
|
日期:2026-06-17
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过 brainstorming,待落实现计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
数字人/图片工作流走 `selfhost` 生成视频时报 `Submit workflow failed: [401] AuthenticationRequired`。
|
||||||
|
根因经多层验证已锁定:
|
||||||
|
|
||||||
|
- Pixelle 的 comfykit 0.1.12 `HttpExecutor` 说的是 **ComfyUI 原生协议**:
|
||||||
|
`POST {base_url}/prompt`、`GET {base_url}/history/{id}`、鉴权用 `Authorization: Bearer`。
|
||||||
|
- 配置里 `comfyui_url = https://cloud.comfy.org`,但 **Comfy Cloud 的 API 契约完全不同**
|
||||||
|
(官方文档 https://docs.comfy.org/development/cloud/overview):
|
||||||
|
- 鉴权:`X-API-Key` 头(非 Bearer)。
|
||||||
|
- 提交:`POST /api/prompt`(带 `/api/` 前缀)。
|
||||||
|
- 轮询:`GET /api/job/{prompt_id}/status` → 状态 pending/in_progress/completed/failed/cancelled。
|
||||||
|
- 详情/产出:`GET /api/jobs/{prompt_id}`(产出按 node_id 聚合 images/video/audio)。
|
||||||
|
- 下载:`GET /api/view?filename=&subfolder=&type=output`(302 跳带签名 URL)。
|
||||||
|
- 接受标准 ComfyUI API-format 工作流。
|
||||||
|
- 已用官方方式验证 Comfy Cloud key 有效:`GET /api/user` 返回 200 `{"status":"active"}`。
|
||||||
|
|
||||||
|
所以这不是 key 失效,而是 comfykit 与 Comfy Cloud 协议不兼容(路径前缀 + 鉴权头 + 轮询端点三处全不同)。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在 **BoosAPI** 里实现一个 **ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器**:对外暴露
|
||||||
|
ComfyUI 原生端点(`/prompt`、`/history/{id}`、`/view`),内部翻译成 Comfy Cloud 的
|
||||||
|
`/api/*` 契约 + `X-API-Key` 鉴权。Pixelle/comfykit **一行代码都不改**,只把 `comfyui_url`
|
||||||
|
指向 BoosAPI 的适配端点即可。
|
||||||
|
|
||||||
|
Comfy Cloud 的 base URL + API Key 通过**站点管理**录入(新增 `comfy_cloud` 平台类型),
|
||||||
|
适配器从站点配置读取,而非散落的环境变量/配置文件。
|
||||||
|
|
||||||
|
## 已确认的关键决策
|
||||||
|
|
||||||
|
1. 采用 **Approach A**:站点管理只作「配置载体 + 前端入口」(存 base_url + X-API-Key),
|
||||||
|
执行逻辑由独立的 Comfy Cloud 适配器承担;**不**把 Comfy Cloud 塞进 `/v1/*` token 路由
|
||||||
|
(它是工作流执行 API,不是聊天协议,塞进 token-router 跑不通)。
|
||||||
|
2. 适配器对外说 ComfyUI 原生协议,Pixelle/comfykit 零改动,只改 `comfyui_url` 配置。
|
||||||
|
3. 鉴权翻译:入口端点不强制校验(本机互信)/出口注入 `X-API-Key`。
|
||||||
|
4. 分支 `feat/comfy-cloud-adapter`,与 NIT 工作无关,不混入。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改 Pixelle / comfykit 源码。
|
||||||
|
- 不做 WebSocket 实时进度(comfykit HTTP 模式只轮询 `/history/{id}`,够用)。
|
||||||
|
- 不把 Comfy Cloud 接入 LLM token 路由/failover。
|
||||||
|
- 不做 Comfy Cloud 的计费集成(已有 `fetch_comfy_cloud_balance` 另说)。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Pixelle(comfykit 0.1.12, 不改)
|
||||||
|
comfyui_url = http://<boosapi-host>/comfy-cloud
|
||||||
|
说 ComfyUI 原生协议: POST /prompt · GET /history/{id} · GET /view · Bearer(忽略)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
BoosAPI 适配端点(新增 src/routes/comfy_cloud_adapter.rs)
|
||||||
|
· POST /comfy-cloud/prompt → 翻译 → POST cloud.comfy.org/api/prompt
|
||||||
|
· GET /comfy-cloud/history/{id} → 翻译 → GET /api/job/{id}/status (+ /api/jobs/{id})
|
||||||
|
拼回 ComfyUI history 格式
|
||||||
|
· GET /comfy-cloud/view → 翻译 → GET /api/view (跟随 302)
|
||||||
|
鉴权: 出口注入 X-API-Key(从站点配置读)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Comfy Cloud (https://cloud.comfy.org/api/*)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 站点管理集成(配置载体)
|
||||||
|
|
||||||
|
- 前端 `SITE_PLATFORM_OPTIONS`(`src/web/pages/Sites.tsx`)新增一项
|
||||||
|
`{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API' }`。
|
||||||
|
- 站点记录复用现有字段:`url` 存 `https://cloud.comfy.org`,API Key 存入现有的
|
||||||
|
站点凭证字段(沿用站点已有的 key/header 存储方式;实现时确认 sites 表里承载 key 的列)。
|
||||||
|
- 适配器通过一个 helper `resolve_comfy_cloud_site(state) -> (base_url, api_key)`:
|
||||||
|
查 `platform = 'comfy_cloud'` 且 enabled 的站点,取其 url + key。无配置 → 明确报错。
|
||||||
|
|
||||||
|
## 适配端点详细行为
|
||||||
|
|
||||||
|
### POST /comfy-cloud/prompt
|
||||||
|
- 入参:ComfyUI 原生 body `{ "prompt": {...}, "client_id": "..." }`。
|
||||||
|
- 出口:`POST {base_url}/api/prompt`,头 `X-API-Key: {key}` + `Content-Type: application/json`,
|
||||||
|
body 原样转发(Comfy Cloud 接受相同的 `{prompt: ...}` 包裹)。
|
||||||
|
- 回包:Comfy Cloud 返回 `{ "prompt_id": "..." }`,原样回给 comfykit(字段名一致,无需改)。
|
||||||
|
|
||||||
|
### GET /comfy-cloud/history/{prompt_id}
|
||||||
|
comfykit 轮询此端点,期待 ComfyUI history 格式:
|
||||||
|
`{ "{prompt_id}": { "status": {...}, "outputs": { "{node_id}": { "images": [...] } } } }`。
|
||||||
|
适配器:
|
||||||
|
1. `GET {base_url}/api/job/{prompt_id}/status` 拿 `{ "status": "completed|in_progress|..." }`。
|
||||||
|
2. 若未完成(pending/in_progress)→ 回 `{}`(comfykit 据此继续轮询)。
|
||||||
|
3. 若 completed → `GET {base_url}/api/jobs/{prompt_id}` 拿产出,翻译成 ComfyUI history 形状:
|
||||||
|
```json
|
||||||
|
{ "{prompt_id}": {
|
||||||
|
"status": { "completed": true, "status_str": "success" },
|
||||||
|
"outputs": { /* node_id → {images|video|audio: [{filename,subfolder,type}]} */ }
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
4. 若 failed/cancelled → 回带 `status_str: "error"` 的 history,让 comfykit 终止轮询并报错。
|
||||||
|
|
||||||
|
### GET /comfy-cloud/view
|
||||||
|
- 透传 query(`filename`/`subfolder`/`type`)→ `GET {base_url}/api/view?...`,头注入 X-API-Key。
|
||||||
|
- Comfy Cloud 回 302 签名 URL → 适配器跟随重定向取字节流回传(reqwest 默认跟随,
|
||||||
|
或手动取 Location 再 GET),Content-Type 透传。
|
||||||
|
|
||||||
|
## 数据流(comfykit 视角,全程无感)
|
||||||
|
|
||||||
|
1. comfykit `POST {comfyui_url}/prompt` → 适配器 → Comfy Cloud → 返回 prompt_id。
|
||||||
|
2. comfykit 轮询 `GET {comfyui_url}/history/{id}` → 适配器翻译 job 状态 → 未完成回 `{}`,
|
||||||
|
完成回 ComfyUI history。
|
||||||
|
3. comfykit 从 outputs 取 filename → `GET {comfyui_url}/view?filename=...` → 适配器取回字节。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 站点未配置 `comfy_cloud` → 适配端点返回 503 + 中文提示「未配置 Comfy Cloud 站点」。
|
||||||
|
- Comfy Cloud 返回非 2xx → 适配器透传状态码 + 上游错误体(便于排查,不吞错)。
|
||||||
|
- job failed/cancelled → 在 history 里标 error,comfykit 会抛 `Submit workflow failed` 类错误
|
||||||
|
(但这次是真实的执行失败,不是鉴权问题)。
|
||||||
|
|
||||||
|
## 路由挂载
|
||||||
|
|
||||||
|
`src/routes/mod.rs` 的 `build_router` 加 `.merge(comfy_cloud_adapter::routes())`。
|
||||||
|
端点前缀 `/comfy-cloud/*`,不需要 admin/user 鉴权(本机内 Pixelle → BoosAPI 互信;
|
||||||
|
如需收紧可后续加内部 token,本期不做)。
|
||||||
|
|
||||||
|
## 测试策略(TDD)
|
||||||
|
|
||||||
|
- 单测翻译函数:Comfy Cloud `job status + jobs detail` JSON → ComfyUI history JSON 的纯函数
|
||||||
|
(`comfy_cloud_job_to_history`),覆盖 completed / in_progress / failed 三种。
|
||||||
|
- 单测 `resolve_comfy_cloud_site`:有配置返回 (url,key);无配置报错。
|
||||||
|
- 用 wiremock 起一个假 Comfy Cloud,集成测 `POST /comfy-cloud/prompt` 透传 + X-API-Key 注入、
|
||||||
|
`GET /comfy-cloud/history/{id}` 轮询翻译(沿用项目现有 wiremock 测试范式)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。
|
||||||
|
- 真实联调:把 Pixelle `comfyui_url` 指向 `http://<boosapi>/comfy-cloud`,跑一次 selfhost 视频,
|
||||||
|
确认不再 401 且能取回产出。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`,提交按逻辑拆分(站点平台类型 / 适配器 service + 端点 /
|
||||||
|
路由挂载 / 前端平台选项),沿用项目中文 commit 风格。
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)设计
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过调查,待用户复审
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
LTX2.3 说话头像工作流(`comfy/video_digital_ltx2.json`)已就绪并通过 Comfy Cloud 校验
|
||||||
|
(节点+模型全兼容,probe `node_errors:{}`)。但 BoosAPI 数字人 UI 没有「视频/动画工作流」
|
||||||
|
入口,无法触发「肖像图 + 旁白音频 → 说话视频」这一步。本设计补齐贯穿三层的入口。
|
||||||
|
|
||||||
|
## 现状(调查结论)
|
||||||
|
|
||||||
|
- **BoosAPI 数字人前端 `src/web/pages/DigitalHuman.tsx`**:仅 3 个选择器——ttsWorkflow、
|
||||||
|
imageWorkflow(从 mediaWorkflows 选,默认含 "image" 的)、frameTemplate(HTML 模板)。
|
||||||
|
**无视频/动画工作流选择器**。流程是「生成图片 + TTS + HTML 模板合成」,不做说话头像动画。
|
||||||
|
- **BoosAPI 后端 `src/routes/digital_human.rs`** 的 `pixelle_request_body`(~line 2053):转发
|
||||||
|
text/mode/title/backend_provider/workflow_source/n_scenes/video_fps/frame_template/
|
||||||
|
template_params/prompt_prefix/bgm_*/media_workflow/tts_workflow/ref_audio。
|
||||||
|
**不转发 `api_video_workflow`**;`workflow_source` 写死 `runninghub`/`selfhost`(无 comfy)。
|
||||||
|
- **Pixelle `pixelle_video/pipelines/asset_based.py`**(~line 693):动画步读
|
||||||
|
`context.request.get("api_video_workflow")`;`audio_path` 仅当 `_get_api_workflow_info`
|
||||||
|
(查 `api_media`,只 `api/` 通道)匹配到 `audio_driven_i2v` 能力时才注入 → **comfy 工作流
|
||||||
|
拿不到 audio_path**。
|
||||||
|
- **Pixelle 工作流执行按前缀路由**:`runninghub` 走 RunningHub,其余(含 `comfy/`)走
|
||||||
|
`comfyui_url`(= BoosAPI `/comfy-cloud` 适配器)。`comfy/video_digital_ltx2.json` 已在
|
||||||
|
`/workflows/media` 列出(已加 `video_` 前缀)。
|
||||||
|
- 已完成的前置:comfy-cloud 适配器(已验证)、`comfy/video_digital_ltx2.json`(标记
|
||||||
|
image_path/audio_path/prompt,probe 通过)、media.py 转发 image_path(已打补丁)。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
1. 加完整视频入口:前端选择器 + 后端转发 + Pixelle 音频注入,三层都改。
|
||||||
|
2. 视频工作流选择器列 `video_*` media 工作流(含 `comfy/video_digital_ltx2.json`)。
|
||||||
|
3. 单人肖像说话头像;不做多人。
|
||||||
|
|
||||||
|
## 三层改动设计
|
||||||
|
|
||||||
|
### 层 1:前端 `src/web/pages/DigitalHuman.tsx`
|
||||||
|
- 新增 state `apiVideoWorkflow`(默认空=不启用动画,保持现有「图片+HTML」行为向后兼容)。
|
||||||
|
- 新增「视频工作流(可选)」`<select>`:选项 = `mediaWorkflows.filter(name 含 "video")`,
|
||||||
|
含空选项「不生成视频(仅图片合成)」。
|
||||||
|
- 提交 payload 增加 `apiVideoWorkflow` 字段;draft 持久化同步。
|
||||||
|
|
||||||
|
### 层 2:BoosAPI 后端 `src/routes/digital_human.rs`
|
||||||
|
- `pixelle_request_body` 增加:
|
||||||
|
```rust
|
||||||
|
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||||
|
request_body["api_video_workflow"] = json!(value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 若所选视频工作流以 `comfy/` 开头,确保 `workflow_source` 不会阻断(comfy 工作流靠前缀
|
||||||
|
路由,workflow_source 主要影响 media/tts 默认通道;api_video_workflow 独立按前缀执行,
|
||||||
|
无需改 workflow_source。实现时验证不冲突)。
|
||||||
|
- 可选转发 `api_video_params`(若前端给了音频驱动等参数)。本期最小化:只转发 api_video_workflow。
|
||||||
|
|
||||||
|
### 层 3:Pixelle `pixelle_video/pipelines/asset_based.py`
|
||||||
|
- 动画步注入 `audio_path` 的条件放宽:除 `api_media` 能力匹配外,**当 `api_video_workflow`
|
||||||
|
以 `comfy/` 开头时,也注入 `audio_path = frame.audio_path`**(comfy 工作流的 `$~audio_path!`
|
||||||
|
标记需要它)。最小改动:在 `audio_driven_i2v` 判断旁边加 comfy 前缀分支。
|
||||||
|
- 镜像内置文件,改后重启容器,备份到 `/opt/Pixelle-Video/patches/`。
|
||||||
|
|
||||||
|
## 数据流(目标)
|
||||||
|
|
||||||
|
```
|
||||||
|
DigitalHuman.tsx: 选 comfy/video_digital_ltx2.json (apiVideoWorkflow)
|
||||||
|
→ BoosAPI digital_human.rs: request_body.api_video_workflow
|
||||||
|
→ Pixelle /api/video/generate/async: context.request.api_video_workflow
|
||||||
|
→ asset_based.py 动画步: 注入 image_path(media.py已补)+ audio_path(本期补)+ prompt
|
||||||
|
→ media() comfy 前缀 → comfyui_url=/comfy-cloud 适配器 → Comfy Cloud LTX2.3
|
||||||
|
→ 说话视频 → 帧合成/HTML 模板 → 成片
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 前端 typecheck:`npm run typecheck:web`。
|
||||||
|
- 后端构建:`cargo build -j 2`。
|
||||||
|
- Pixelle 改动:语法校验 + 重启 healthy。
|
||||||
|
- 端到端:数字人任务选 `comfy/video_digital_ltx2.json`,确认 Comfy Cloud 出现 job、
|
||||||
|
注入了 image_path+audio_path、出说话视频。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改 comfy-cloud 适配器(已完成)。
|
||||||
|
- 不做多人对话数字人。
|
||||||
|
- 不重构数字人现有「图片+HTML 合成」路径(视频入口为可选叠加,默认不启用,向后兼容)。
|
||||||
|
- 不动 RunningHub 数字人路径。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`。前端、BoosAPI 后端分别提交(项目内,受版本控制);
|
||||||
|
Pixelle 改动在容器内 + 备份 patches/(非本仓库)。中文 commit。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 数字人脚本 LLM 模型可选 设计
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过 brainstorming,待用户复审
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
数字人脚本生成的 LLM 模型现在写死在 Pixelle config(`llm.model = gpt-5.5`),用户无法在
|
||||||
|
数字人页面选用别的模型。本功能加一个「脚本模型(可选)」下拉,让用户自选生成脚本的 LLM。
|
||||||
|
生图模型已可通过现有「图片工作流」下拉选择,不在本范围。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
1. 只做脚本 LLM 模型可选;生图模型已由「图片工作流」下拉覆盖,不动。
|
||||||
|
2. 下拉只列文本/对话模型(过滤掉生图/视频模型,避免选错导致生成失败)。
|
||||||
|
3. 默认空 = 用 Pixelle config 的默认模型(`deepseek-v4-flash`),向后兼容。实现时把 Pixelle
|
||||||
|
`config.yaml` 的 `llm.model` 设为 `deepseek-v4-flash`。
|
||||||
|
4. 三层改动(前端选择器 + 后端转发 + Pixelle 传 model)。
|
||||||
|
|
||||||
|
## 集成点(调查结论)
|
||||||
|
|
||||||
|
- Pixelle `llm_service.py`:`llm(prompt=, model=...)` **已支持按调用传 model 覆盖**
|
||||||
|
(优先级:参数 > config)。
|
||||||
|
- Pixelle `asset_based.py` 脚本生成调用 `self.core.llm(prompt=, response_type=VideoScript, ...)`
|
||||||
|
**当前未传 model** → 需补 `model=context.request.get("llm_model")`。
|
||||||
|
- BoosAPI `digital_human.rs` `pixelle_request_body` 转发字段(媒体/tts 工作流等),
|
||||||
|
**未转发 llm_model** → 需补。
|
||||||
|
- 前端模型列表:`api.getModelsMarketplace()`(`/api/models/marketplace`)已有,可作下拉数据源。
|
||||||
|
- `/v1/models` 需鉴权(401 直取不行),故用 marketplace 接口。
|
||||||
|
|
||||||
|
## 三层改动设计
|
||||||
|
|
||||||
|
### 层1 前端 `src/web/pages/DigitalHuman.tsx`
|
||||||
|
- 加 state `llmModel`(默认空)+ draft 持久化。
|
||||||
|
- 加载时调 `api.getModelsMarketplace()` 取模型,过滤掉生图/视频模型(名称含
|
||||||
|
image/video/dall/flux/sd/wan/seedance/kling/banana 等,或按 marketplace 项的类型字段),
|
||||||
|
得文本模型列表 state `llmModels`。
|
||||||
|
- 加「脚本模型(可选)」`<select>`:空选项「默认(deepseek-v4-flash)」+ 文本模型选项。
|
||||||
|
- 提交 payload 加 `llm_model: llmModel || undefined`。
|
||||||
|
|
||||||
|
### 层2 后端 `src/routes/digital_human.rs`
|
||||||
|
- `pixelle_request_body` 加(照搬 tts_workflow 转发模式):
|
||||||
|
```rust
|
||||||
|
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
|
||||||
|
request_body["llm_model"] = json!(value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 层3 Pixelle `pixelle_video/pipelines/asset_based.py`
|
||||||
|
- 脚本生成调用前取 `llm_model = context.request.get("llm_model")`,非空则传入:
|
||||||
|
`self.core.llm(prompt=prompt, response_type=VideoScript, temperature=0.8, max_tokens=4000, model=llm_model)`。
|
||||||
|
(llm() 已接受 model 参数;None 时走 config 默认。)
|
||||||
|
- 容器内改 + 备份到 `/opt/Pixelle-Video/patches/`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 前端 `npm run typecheck:web` 干净。
|
||||||
|
- 后端 `cargo build -j 2` 通过。
|
||||||
|
- Pixelle 语法校验 + 重启 healthy。
|
||||||
|
- 端到端:数字人任务选非默认脚本模型,Pixelle 日志出现 `LLM call: model=<所选模型>`。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改生图模型选择(已由图片工作流下拉覆盖)。
|
||||||
|
- 不做参考图上传(功能 B,留后续)。
|
||||||
|
- 不动 comfy-cloud 适配器、LTX2.3 工作流。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`。前端、后端分别提交;Pixelle 容器内改 + 备份(非本仓库)。中文 commit。
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# LTX2.3 数字人工作流(Comfy Cloud)设计
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过 brainstorming,待落实现计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Pixelle 数字人(说话头像)能力原先只有 RunningHub 形态(`runninghub/digital_*.json` 是
|
||||||
|
`{"source":"runninghub","workflow_id":"..."}` 指针,非 ComfyUI 节点图,无法在 Comfy Cloud 跑)。
|
||||||
|
为让数字人走 Comfy Cloud,采用官方 ComfyUI 模板 `video_ltx2_3_id_lora`(LTX2.3 身份保持
|
||||||
|
LoRA + 音频驱动),把它做成 Pixelle 的 `comfy/` 工作流,经已建好的 comfy-cloud 适配器执行。
|
||||||
|
|
||||||
|
可行性已验证(查 Comfy Cloud `/object_info` 3548 节点 + 模型池):
|
||||||
|
- 模型齐全:`ltx-2.3-22b-dev-fp8`、`ltx-2.3-id-lora-talkvid-3k`、`gemma_3_12B_it_fp4_mixed`、
|
||||||
|
distilled LoRA 均托管;另有 `ltx-2.3-22b-ic-lora-lipdub-0.9` 口型 LoRA。
|
||||||
|
- 节点齐全:LTX2.3 音视频节点(LTXVReferenceAudio/LTXVConcatAVLatent/LTXVAudioVAEDecode 等)
|
||||||
|
全在 Comfy Cloud 节点集中。
|
||||||
|
- 模板 I/O = LoadImage(肖像)+ LoadAudio(旁白)+ 文本 → SaveVideo(带音频说话视频),
|
||||||
|
正好匹配数字人。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
1. 用官方模板 `video_ltx2_3_id_lora`(LTX2.3),不自拼 InfiniteTalk(官方模板天然 Comfy-Cloud
|
||||||
|
兼容、模型已托管、风险低)。
|
||||||
|
2. 接入方式:comfy 通道 + 节点图注入(放 `comfy/digital_ltx2.json`,经适配器),不走 RunningHub、
|
||||||
|
不另做独立入口。
|
||||||
|
3. comfykit 注入机制:节点 `_meta.title` 标记(`$~image!` / `$~audio!` / `$prompt!`)。
|
||||||
|
4. 分支 `feat/comfy-cloud-adapter`(与 comfy-cloud 适配器同分支,属同一能力线)。
|
||||||
|
|
||||||
|
## 前置依赖(硬依赖)
|
||||||
|
|
||||||
|
**需要用户从 Comfy Cloud 导出 API 格式 JSON**:模板当前是 UI 格式(含 subgraph 子图),
|
||||||
|
`/api/prompt` 要求 API 格式(`{node_id:{class_type,inputs,_meta}}`)。子图必须展开。
|
||||||
|
用户操作:Comfy Cloud 网页打开模板/share → Workflow → Export (API Format) → 放服务器
|
||||||
|
(如 `/tmp/ltx2_api.json`)。服务端无法代取(需登录态)。
|
||||||
|
|
||||||
|
## Pixelle media.py 改动(writing-plans 阶段发现的必需项)
|
||||||
|
|
||||||
|
现状:`pixelle_video/services/media.py` 的 `__call__` 只把 `image_path` 转发给 `api/` 通道
|
||||||
|
(`api_media`),**comfy/ 通道的 workflow_params 不含 image_path**。`audio_path` 经 `**params`
|
||||||
|
能进 workflow_params,但肖像图进不去。所以 comfy/ 工作流收不到肖像图。
|
||||||
|
|
||||||
|
改动:在 `media.py` 的非 api/ 分支(构建 `workflow_params` 处),把 `image_path` 也加入
|
||||||
|
`workflow_params`(命名 `image_path`),使其能被 `$~image_path!` 标记注入。一行级小改,
|
||||||
|
但属 Pixelle 源码(非 BoosAPI 仓库),改在容器内 `/app/pixelle_video/services/media.py`,
|
||||||
|
并同步到宿主持久化(确认 media.py 是否在挂载卷;若在镜像内则需固化方案)。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Pixelle 数字人流程
|
||||||
|
生成肖像图(image_path)+ TTS 旁白(audio_path)+ 脚本文本
|
||||||
|
│ comfykit 按 _meta.title 标记注入到 LoadImage/LoadAudio/文本节点
|
||||||
|
▼
|
||||||
|
comfy/digital_ltx2.json (API 格式 + 标记)
|
||||||
|
│ comfyui_url = http://<boosapi>/comfy-cloud
|
||||||
|
▼
|
||||||
|
Comfy Cloud /api/prompt → LTX2.3(id-lora-talkvid + 可选 lipdub)→ 带音频说话视频
|
||||||
|
▼
|
||||||
|
SaveVideo 产出 → 适配器 /view 取回 → Pixelle HTML 模板合成
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流改造(实现步骤)
|
||||||
|
|
||||||
|
基于用户导出的 API-format JSON:
|
||||||
|
1. `LoadImage` 节点 → `_meta.title = "$~image_path!"`(必填、媒体上传;键名对齐 media.py 转发的 image_path)
|
||||||
|
2. `LoadAudio` 节点 → `_meta.title = "$~audio_path!"`(键名对齐 api_video_params 注入的 audio_path)
|
||||||
|
3. 文本提示节点(PrimitiveStringMultiline / CLIPTextEncode)→ `_meta.title = "$prompt!"`(对齐 media.py 的 prompt)
|
||||||
|
4. `SaveVideo` 已知输出节点,自动识别,不标记
|
||||||
|
5. 删除 `RecordAudio` 节点(UI 录音交互,服务端不需要)
|
||||||
|
6. 成品写 `data/workflows/comfy/digital_ltx2.json`(宿主持久化,经 `./data` 挂载)
|
||||||
|
7. 键名以 media.py 实际放入 workflow_params 的为准(image_path/audio_path/prompt),已在 writing-plans 阶段确认。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 校验探测:`POST /comfy-cloud/prompt`(body 为改造后 `{prompt:节点图}`)→ 期望
|
||||||
|
`node_errors:{}` + 返回 prompt_id(不跑完整数字人流程,先验证节点+模型全兼容)。
|
||||||
|
- 真实验证:Pixelle 数字人任务选 `comfy/digital_ltx2.json`,出片确认。
|
||||||
|
- 错误处理沿用适配器:节点不支持→400 透传;模型缺失→校验错误透传;执行失败→job failed。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改 comfy-cloud 适配器代码(已完成且验证)。
|
||||||
|
- 不做多人对话(multi-speaker)数字人,先单人肖像。
|
||||||
|
- 不在 Comfy Cloud 上自训/上传新模型,只用已托管模型。
|
||||||
|
- 不做 RunningHub 数字人的迁移/替换(保留并存)。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`,工作流文件 + 任何 Pixelle 侧配置分开提交,中文 commit。
|
||||||
|
注:工作流文件落在 Pixelle 宿主目录(`/opt/Pixelle-Video/data/workflows/comfy/`),不在
|
||||||
|
BoosAPI 仓库内;若需纳入版本管理另议。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Pixelle 连接凭证纳入 BoosAPI 管理 设计
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过 brainstorming,待用户复审
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Pixelle 的连接凭证(RunningHub API key、Comfy Cloud url/key)现在只能手动编辑
|
||||||
|
`/opt/Pixelle-Video/config.yaml` 再重启容器,很麻烦。本功能把这 3 个凭证纳入 BoosAPI
|
||||||
|
管理控制台,在 UI 里改、一键写入 Pixelle config 并重启生效。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
1. 范围:只管 Pixelle 的 3 个连接凭证 —— `runninghub_api_key`、`comfyui_url`、`comfyui_api_key`。
|
||||||
|
不做整份 config.yaml 编辑,不做通用外部连接管理。
|
||||||
|
2. 同步机制:写文件 + 重启容器(BoosAPI 直接改 bind-mount 的 config.yaml,再 restart pixelle 容器)。
|
||||||
|
不给 Pixelle 加 HTTP 配置端点(避免改 Pixelle 源码)。
|
||||||
|
3. 真相源:BoosAPI settings 表;config.yaml 是被同步的目标。
|
||||||
|
4. 放置:连接管理页(`/accounts`,`src/web/pages/Accounts.tsx`)作为一个卡片/分区。
|
||||||
|
|
||||||
|
## 集成点(调查结论)
|
||||||
|
|
||||||
|
- Pixelle 无配置更新 HTTP 端点;`config.yaml` 是 bind-mount(`/opt/Pixelle-Video/config.yaml`),
|
||||||
|
BoosAPI 跑在宿主,有读写权限。
|
||||||
|
- 凭证在 config.yaml 的 `comfyui:` 段:`runninghub_api_key` / `comfyui_url` / `comfyui_api_key`。
|
||||||
|
- 改后 Pixelle 需重启容器生效(`cd /opt/Pixelle-Video && docker compose restart api`)。
|
||||||
|
- BoosAPI 有 settings 表(key-value)+ builtin_key 掩码范式可复用。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
连接管理页「Pixelle 连接」卡片(3 输入框 + 保存)
|
||||||
|
→ PUT /api/admin/pixelle/connection
|
||||||
|
1. 写 settings 表(pixelle.runninghub_api_key 等)
|
||||||
|
2. 备份 config.yaml → config.yaml.bak-<ts>
|
||||||
|
3. YAML 改 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键(其余保留)
|
||||||
|
4. docker compose restart api(cwd=/opt/Pixelle-Video)
|
||||||
|
→ GET /api/admin/pixelle/connection 返回当前值(密钥掩码)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三层改动设计
|
||||||
|
|
||||||
|
### 层1 后端 service `src/services/pixelle_connection.rs`(新建)
|
||||||
|
- `struct PixelleConnection { runninghub_api_key, comfyui_url, comfyui_api_key }`(均 Option<String>)。
|
||||||
|
- `read_connection(db) -> PixelleConnection`:从 settings 表读 `pixelle.*` 三键。
|
||||||
|
- `save_connection(db, conn)`:三键写 settings 表(UPSERT)。
|
||||||
|
- `sync_to_pixelle(config_path) -> AppResult<()>`:读 yaml → 改 comfyui 三键 → 备份 → 写回。
|
||||||
|
用 `serde_yaml`(确认 Cargo.toml 有;无则加)。只改这三键,其余键原样保留(读成
|
||||||
|
`serde_yaml::Value` 再定点改,不整体重序列化丢注释——注意:serde_yaml 会丢注释,
|
||||||
|
接受此代价,或保留备份)。
|
||||||
|
- `restart_pixelle(compose_dir) -> AppResult<()>`:`docker compose restart api`,cwd=compose_dir。
|
||||||
|
- 配置项:Pixelle config 路径 + compose 目录,默认 `/opt/Pixelle-Video/config.yaml`、
|
||||||
|
`/opt/Pixelle-Video`,从 BoosAPI config(新增 `[pixelle_video] config_path` / 复用现有)读。
|
||||||
|
|
||||||
|
### 层2 后端路由 `src/routes/admin/pixelle_connection.rs`(新建)+ 挂载
|
||||||
|
- `GET /api/admin/pixelle/connection`:返回三值,api_key 掩码(`••••<last4>`)。
|
||||||
|
- `PUT /api/admin/pixelle/connection`:收三字段 → save_connection → sync_to_pixelle → restart。
|
||||||
|
掩码值(`••••`)表示「不改」,跳过该字段。
|
||||||
|
- 挂到 admin router(走现有 require_admin 中间件)。
|
||||||
|
|
||||||
|
### 层3 前端 `src/web/pages/Accounts.tsx` + `api.ts`
|
||||||
|
- `api.ts` adminApi 加 `getPixelleConnection()` / `savePixelleConnection(data)`。
|
||||||
|
- Accounts 页加「Pixelle 连接」卡片:3 输入框(runninghub key、comfy url、comfy key)+ 保存。
|
||||||
|
加载时拉当前值(掩码),保存提示「已保存并重启 Pixelle,约数秒生效」。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- config.yaml 不存在/无写权限 → 503 + 明确提示,**不重启**。
|
||||||
|
- docker restart 失败 → 返回错误,提示「配置已写入,但 Pixelle 重启失败,请手动重启」。
|
||||||
|
- 写文件前必备份;写失败保留原文件。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 后端:rstest 用临时 yaml 测 sync_to_pixelle(读→改3键→保留其余→备份生成);
|
||||||
|
`cargo build -j 2`。
|
||||||
|
- 前端:`npm run typecheck:web`。
|
||||||
|
- docker restart 部分手动验证(端到端:UI 改 runninghub key → 确认 config.yaml 变 + 容器重启)。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不做整份 config.yaml 编辑。
|
||||||
|
- 不做通用「外部服务连接」管理(只 Pixelle)。
|
||||||
|
- 不给 Pixelle 加源码/端点。
|
||||||
|
- 不动 comfy-cloud 适配器、数字人工作流。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`。service / 路由 / 前端 分开提交,中文 commit。
|
||||||
|
serde_yaml 若需新增依赖,单独说明。
|
||||||
@@ -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,69 @@
|
|||||||
|
# workflow_ui.rs 拆分(8 子模块)设计
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
分支:`feat/comfy-cloud-adapter`
|
||||||
|
状态:已通过 brainstorming,待用户复审
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`src/routes/workflow_ui.rs` 单文件 6181 行,是项目最大文件,难维护/难测/edit 易错。
|
||||||
|
按职责拆成 `src/routes/workflow_ui/` 模块目录(8 子模块)。纯机械移动、零逻辑改动,
|
||||||
|
靠 Rust 编译器 + 现有 5 个测试验证。
|
||||||
|
|
||||||
|
## 已确认决策
|
||||||
|
|
||||||
|
1. 第一个优化目标:拆 workflow_ui.rs(项目最大文件)。
|
||||||
|
2. 拆成 8 子模块(见下表)。
|
||||||
|
3. 纯机械移动:函数体一字不改,只调可见性 + use 路径。零逻辑改动。
|
||||||
|
4. 安全策略:每抽一个子模块即 cargo build -j 2 验证;5 个测试全程保持通过;末尾冒烟。
|
||||||
|
|
||||||
|
## 子模块划分
|
||||||
|
|
||||||
|
| 子模块 | 职责 | 现有行号(约) |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| `mod.rs` | routes() 装配 + 共享类型(WorkflowCaller、Query/Payload structs)+ 子模块声明 | 56-124 |
|
||||||
|
| `assets.rs` | UI 静态资源服务、嵌入目录解析、index/static/serve_file/官方模板资源 | 211-305, 1533-1814 |
|
||||||
|
| `auth.rs` | caller 解析(caller_from_headers/auth_caller/caller_user_id/caller_is_admin) | 313-363, 1875 |
|
||||||
|
| `node_defs.rs` | 节点定义 + 工作流模板(core_node_definitions、*_workflow_graph、node_type_def_to_comfy) | 429-932 |
|
||||||
|
| `models.rs` | 模型分类 + folder 模型(is_*_workflow_model、workflow_folder_*、native_folder_models) | 939-1530 |
|
||||||
|
| `comfy_proxy.rs` | ComfyUI API 代理 handler(settings/userdata/object_info/system_stats/extensions 等) | 1883-2200 |
|
||||||
|
| `validate.rs` | 工作流校验(validate_workflow* 三个) | 2206-2586 |
|
||||||
|
| `queue.rs` | 队列/历史/prompt(queue/history/queue_prompt/repair_native_prompt_payload/queue_boosapi_prompt/view) | 2586-3679 |
|
||||||
|
|
||||||
|
> 注:行号是当前快照,实现时以函数名定位为准(文件会随抽取缩短)。各子模块的辅助小函数
|
||||||
|
> 随其主要消费者走;跨多模块共用的(如 guess_mime_from_path、parse_boolish、safe_view_path)
|
||||||
|
> 放 mod.rs 或新建 util.rs(实现时按实际依赖定,优先放 mod.rs 顶部 pub(super))。
|
||||||
|
|
||||||
|
## 安全策略(核心)
|
||||||
|
|
||||||
|
1. **纯移动**:剪切函数到子模块,函数体一字不改。仅:
|
||||||
|
- 可见性:跨模块调用的改 `pub(super)` / `pub(crate)`;
|
||||||
|
- `use`:子模块顶部补齐它用到的依赖路径(crate::、super::、外部 crate)。
|
||||||
|
2. **逐模块验证**:每抽一个子模块就 `cargo build -j 2`。Rust 编译器对未解析引用/类型不符
|
||||||
|
直接报错——这是纯移动重构的强安全网。
|
||||||
|
3. **测试保持**:现有 5 个测试(在文件尾 `#[cfg(test)] mod tests`)迁到相关子模块(多与
|
||||||
|
models/node_defs 相关),全程保持通过:`cargo test -p boosapi --lib routes::workflow_ui`。
|
||||||
|
4. **末尾冒烟**:全部完成后,工作流 UI 打开 + 跑一个 prompt,确认行为不变。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
不变。`routes/mod.rs` 仍 `.merge(workflow_ui::routes())`,对外接口零变化。仅文件内部
|
||||||
|
组织从 1 文件变 8 文件,所有路由路径、handler 行为、类型签名完全一致。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 编译器:每步 cargo build -j 2(主验证手段)。
|
||||||
|
- 单测:迁移后 5 个测试保持通过。
|
||||||
|
- 冒烟:手动开工作流 UI + 跑 prompt。
|
||||||
|
- 不新增功能测试(本任务是重构,非加功能;但若迁移中发现某函数明显缺测且易加,可顺手补)。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不改任何逻辑、不改路由路径、不改 handler 行为。
|
||||||
|
- 不拆其他巨文件(oauth.rs/digital_human.rs/user/mod.rs 各自独立后续循环)。
|
||||||
|
- 不顺手重构/优化函数实现(纯搬运)。
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
分支 `feat/comfy-cloud-adapter`。**按子模块分多次提交**(每抽 1-2 个子模块 + 编译通过即提交),
|
||||||
|
便于回溯;不一锅烩。中文 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);
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
|
"build:all": "node scripts/build-all.mjs",
|
||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# 2026-05-15 技术报告 / Technical Report
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Metapi → BoosAPI 全局改名 / Global Rename
|
|
||||||
|
|
||||||
### 1.1 变更范围 / Change Scope
|
|
||||||
|
|
||||||
| 区域 / Area | 变更内容 / Changes |
|
|
||||||
|---|---|
|
|
||||||
| UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
|
|
||||||
| i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
|
|
||||||
| 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
|
|
||||||
| 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
|
|
||||||
|
|
||||||
### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
|
|
||||||
|
|
||||||
| 项目 / Item | 变更说明 / Description |
|
|
||||||
|---|---|
|
|
||||||
| HTTP Header | `x-metapi-*` → `x-boosapi-*` |
|
|
||||||
| 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
|
|
||||||
| 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
|
|
||||||
| 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
|
|
||||||
| Package Name | `package.json` 中 `name` 更新为 `boosapi` |
|
|
||||||
|
|
||||||
### 1.3 验证方式 / Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
|
|
||||||
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
|
|
||||||
```
|
|
||||||
|
|
||||||
预期结果:无输出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ComfyUI Agent 对话助手 / Conversational Assistant
|
|
||||||
|
|
||||||
### 2.1 功能概述 / Feature Overview
|
|
||||||
|
|
||||||
侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
|
|
||||||
|
|
||||||
### 2.2 技术架构 / Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
|
|
||||||
→ SSE 事件流 → BoosAPI 转发 → 浏览器
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 后端实现 / Backend
|
|
||||||
|
|
||||||
| 文件 / File | 作用 / Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
|
|
||||||
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
|
|
||||||
|
|
||||||
### 2.4 前端实现 / Frontend
|
|
||||||
|
|
||||||
| 文件 / File | 作用 / Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
|
|
||||||
| `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
|
|
||||||
|
|
||||||
### 2.5 SSE 事件类型 / SSE Event Types
|
|
||||||
|
|
||||||
| `type` | 含义 / Meaning | 前端渲染 / Rendering |
|
|
||||||
|---|---|---|
|
|
||||||
| `text` | 流式文本 | 追加到对话内容 |
|
|
||||||
| `character_prompts` | 四角度角色提示词 | 角色卡片 |
|
|
||||||
| `image` | 生成的角色图像 | 内联图片 |
|
|
||||||
| `scene` | 场景提示词 | 场景块 |
|
|
||||||
| `tts` | 语音音频 | 音频播放器 |
|
|
||||||
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
|
|
||||||
| `done` | 流结束 | 停止等待 |
|
|
||||||
|
|
||||||
### 2.6 验证结果 / Verification
|
|
||||||
|
|
||||||
| 命令 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| `npm install` | ✅ |
|
|
||||||
| `npm run typecheck:web` | ✅ 0 error |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gitea CI/CD Runner 部署 / Runner Deployment
|
|
||||||
|
|
||||||
### 3.1 部署信息 / Deployment Info
|
|
||||||
|
|
||||||
| 项目 | 详情 |
|
|
||||||
|---|---|
|
|
||||||
| 系统 | Alpine Linux, OpenRC |
|
|
||||||
| 部署方式 | 裸机直跑,无 DinD |
|
|
||||||
| Runner 版本 | v0.1.3 |
|
|
||||||
| 注册模式 | `:host` 标签 |
|
|
||||||
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
|
|
||||||
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
|
|
||||||
| `NO_PROXY` | 包含 `git.boos.lat` |
|
|
||||||
| 持久化 | `ephemeral: false` |
|
|
||||||
|
|
||||||
### 3.2 已知限制 / Known Limitations
|
|
||||||
|
|
||||||
- 不支持 Docker in Docker;CI 使用 `runs-on: host`
|
|
||||||
- 无原生 artifact 存储;构建产物需手动备份
|
|
||||||
- `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Notification Service
|
|
||||||
|
|
||||||
### 4.1 支持通道 / Supported Channels
|
|
||||||
|
|
||||||
| 通道 | 配置前缀 | 协议 |
|
|
||||||
|---|---|---|
|
|
||||||
| Telegram | `telegram*` | Bot API |
|
|
||||||
| 企业微信 | `wecom*` | Webhook |
|
|
||||||
| 飞书 | `feishu*` | Webhook |
|
|
||||||
| 通用 Webhook | `webhook*` | JSON POST |
|
|
||||||
| Bark | `bark*` | HTTP, iOS |
|
|
||||||
| Server酱 | `serverchan*` | HTTP |
|
|
||||||
| SMTP | `smtp*` | Email |
|
|
||||||
|
|
||||||
### 4.2 Telegram 消息格式
|
|
||||||
|
|
||||||
- 纯文本,不启用 HTML/Markdown 解析
|
|
||||||
- 单条最大 3900 字符,超长截断 + `...(truncated)`
|
|
||||||
- 支持 Telegram Topics(`message_thread_id`)
|
|
||||||
|
|
||||||
### 4.3 节流机制 / Throttling
|
|
||||||
|
|
||||||
- 基于通知签名的全局冷静期:`notifyCooldownSec`
|
|
||||||
- 同一签名在冷静期内重复触发时合并为一次
|
|
||||||
- 冷静期为全局共享,不按通道独立
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 部署文档完善 / Deployment Documentation
|
|
||||||
|
|
||||||
Commit: `5bd847d`
|
|
||||||
|
|
||||||
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
|
|
||||||
|
|
||||||
- 完整 `docker-compose.yml` 示例(含 healthcheck)
|
|
||||||
- `.env` 变量插值模式
|
|
||||||
- 全部环境变量参考表(20+ 变量)
|
|
||||||
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
|
|
||||||
- 生产部署检查清单
|
|
||||||
- Nginx 反向代理(WebSocket + SSE)
|
|
||||||
- 管理后台 IP 白名单限制示例
|
|
||||||
- Docker run 常用选项表
|
|
||||||
- 升级/回滚操作流程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 待办事项 / TODO
|
|
||||||
|
|
||||||
| 优先级 | 事项 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
|
|
||||||
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
|
|
||||||
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
# 内置 ComfyUI 视频助手 + SaaS 多租户方案
|
|
||||||
|
|
||||||
> 生成: gpt-5.5 via api.boos.lat | 2026-05-18
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 数据表设计 (SQLite migrations)
|
|
||||||
|
|
||||||
### 1.1 `tenants` 租户
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tenants (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
slug TEXT UNIQUE NOT NULL,
|
|
||||||
plan TEXT NOT NULL DEFAULT 'free',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
owner_user_id TEXT NOT NULL,
|
|
||||||
settings TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 `tenant_members` 租户成员
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tenant_members (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(tenant_id, user_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 `subscriptions` 订阅与额度
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE subscriptions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
plan TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
monthly_generations_limit INTEGER NOT NULL DEFAULT 100,
|
|
||||||
monthly_video_seconds_limit INTEGER NOT NULL DEFAULT 300,
|
|
||||||
storage_bytes_limit INTEGER NOT NULL DEFAULT 5368709120,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 `usage_counters` 用量统计
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE usage_counters (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
period TEXT NOT NULL,
|
|
||||||
generations_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
video_seconds_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
storage_bytes_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ai_tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(tenant_id, period)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.5 `comfy_workflows` 工作流模板
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE comfy_workflows (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
type TEXT NOT NULL DEFAULT 'video',
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'private',
|
|
||||||
workflow_json TEXT NOT NULL,
|
|
||||||
input_schema TEXT NOT NULL DEFAULT '{}',
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_by TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.6 `video_jobs` 视频任务
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE video_jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT,
|
|
||||||
workflow_id TEXT REFERENCES comfy_workflows(id),
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
priority INTEGER NOT NULL DEFAULT 100,
|
|
||||||
prompt TEXT,
|
|
||||||
negative_prompt TEXT,
|
|
||||||
params TEXT NOT NULL DEFAULT '{}',
|
|
||||||
comfy_prompt_id TEXT,
|
|
||||||
progress REAL NOT NULL DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
result_asset_id TEXT,
|
|
||||||
estimated_video_seconds INTEGER DEFAULT 0,
|
|
||||||
actual_video_seconds INTEGER DEFAULT 0,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.7 `assets` 文件资产
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE assets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT,
|
|
||||||
job_id TEXT REFERENCES video_jobs(id) ON DELETE SET NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
mime_type TEXT,
|
|
||||||
file_name TEXT,
|
|
||||||
storage_path TEXT NOT NULL,
|
|
||||||
public_url TEXT,
|
|
||||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API 路由设计
|
|
||||||
|
|
||||||
### 租户管理
|
|
||||||
```
|
|
||||||
POST /api/tenants → 创建租户
|
|
||||||
GET /api/tenants → 我的租户列表
|
|
||||||
GET /api/tenants/:id → 租户详情
|
|
||||||
PATCH /api/tenants/:id → 更新租户
|
|
||||||
```
|
|
||||||
|
|
||||||
### 成员管理
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/members → 成员列表
|
|
||||||
POST /api/tenants/:id/members/invite → 邀请成员
|
|
||||||
DELETE /api/tenants/:id/members/:uid → 移除成员
|
|
||||||
PATCH /api/tenants/:id/members/:uid/role → 修改角色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 视频助手
|
|
||||||
```
|
|
||||||
POST /api/tenants/:id/video/analyze → 解析脚本(角色/场景/对话)
|
|
||||||
POST /api/tenants/:id/video/generate → 生成视频任务
|
|
||||||
GET /api/tenants/:id/video/jobs → 任务列表
|
|
||||||
GET /api/tenants/:id/video/jobs/:jid → 任务详情
|
|
||||||
POST /api/tenants/:id/video/jobs/:jid/cancel → 取消任务
|
|
||||||
GET /api/tenants/:id/video/jobs/:jid/events → SSE 进度
|
|
||||||
```
|
|
||||||
|
|
||||||
### ComfyUI 工作流
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/workflows → 工作流模板列表
|
|
||||||
POST /api/tenants/:id/workflows → 创建模板
|
|
||||||
GET /api/tenants/:id/workflows/:wid → 模板详情
|
|
||||||
DELETE /api/tenants/:id/workflows/:wid → 删除模板
|
|
||||||
```
|
|
||||||
|
|
||||||
### 资产生成(ai Helper)
|
|
||||||
```
|
|
||||||
POST /api/tenants/:id/ai/character → gpt-image-2 生成角色四视图
|
|
||||||
POST /api/tenants/:id/ai/scene → 生成场景图
|
|
||||||
POST /api/tenants/:id/ai/tts → TTS 语音
|
|
||||||
POST /api/tenants/:id/ai/prompt-optimize → 优化视频 prompt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 用量与订阅
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/usage → 当前用量
|
|
||||||
GET /api/tenants/:id/subscription → 订阅信息
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 前端页面/组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/web/pages/
|
|
||||||
├── VideoAssistant.tsx ← 视频助手主页面(prompt→生成→预览)
|
|
||||||
├── VideoJobs.tsx ← 历史任务列表
|
|
||||||
├── WorkflowLibrary.tsx ← 工作流模板库
|
|
||||||
├── TenantSettings.tsx ← 租户设置
|
|
||||||
└── TenantMembers.tsx ← 成员管理
|
|
||||||
|
|
||||||
src/web/components/
|
|
||||||
├── video/
|
|
||||||
│ ├── ScriptEditor.tsx ← 自然语言脚本编辑
|
|
||||||
│ ├── ScriptPreview.tsx ← 脚本解析预览(角色/场景/对话)
|
|
||||||
│ ├── CharacterCard.tsx ← 角色四视图卡片
|
|
||||||
│ ├── SceneCard.tsx ← 场景图卡片
|
|
||||||
│ ├── TTSPlayer.tsx ← TTS 语音播放
|
|
||||||
│ ├── WorkflowExport.tsx ← ComfyUI 工作流导出
|
|
||||||
│ └── VideoPlayer.tsx ← 视频播放/下载
|
|
||||||
├── tenant/
|
|
||||||
│ ├── TenantSwitcher.tsx ← 租户切换
|
|
||||||
│ ├── MemberList.tsx ← 成员列表
|
|
||||||
│ ├── InviteDialog.tsx ← 邀请对话框
|
|
||||||
│ └── UsageQuota.tsx ← 用量/配额显示
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 核心业务流程
|
|
||||||
|
|
||||||
### 注册 → 创作 → 导出
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 注册/登录
|
|
||||||
2. 自动创建个人租户(或个人空间)
|
|
||||||
3. 进入视频助手页面
|
|
||||||
4. 输入自然语言脚本
|
|
||||||
5. 系统解析 → 提取角色/场景/对话
|
|
||||||
6. 自动调用 AI 生成:
|
|
||||||
- 【CHARACTER】→ gpt-image-2 四视图
|
|
||||||
- 【SCENE】 → 场景图
|
|
||||||
- 【TTS】 → 语音
|
|
||||||
- 【WORKFLOW】 → ComfyUI 工作流 JSON
|
|
||||||
7. 预览所有生成内容
|
|
||||||
8. 导出/下载/提交 ComfyUI 执行
|
|
||||||
9. 视频生成 → 预览 → 下载
|
|
||||||
|
|
||||||
如需协作: → 创建组织 → 邀请成员 → 设置角色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 租户管理流程
|
|
||||||
|
|
||||||
```
|
|
||||||
创建组织
|
|
||||||
→ 成为 owner
|
|
||||||
→ 可创建 API Key
|
|
||||||
→ 可邀请成员(邮箱/链接)
|
|
||||||
→ 角色: owner / admin / member
|
|
||||||
→ 每个成员有独立用量跟踪
|
|
||||||
|
|
||||||
套餐限制:
|
|
||||||
free: 50 次/月, 5GB 存储, 基础功能
|
|
||||||
pro: 500 次/月, 50GB, 全功能+更高配额
|
|
||||||
custom: 定制
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 文件变更清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
|
|
||||||
**后端 Rust:**
|
|
||||||
```
|
|
||||||
src/routes/tenants/mod.rs → 租户 CRUD
|
|
||||||
src/routes/tenants/{handlers,types}.rs
|
|
||||||
src/routes/video_jobs/ → 视频任务管理
|
|
||||||
src/routes/video_jobs/{handlers,sse}.rs
|
|
||||||
src/routes/ai_assist.rs → AI 辅助生成(角色/场景/TTS/prompt)
|
|
||||||
src/routes/subscriptions.rs → 订阅/用量
|
|
||||||
src/services/tenant_service.rs → 租户业务逻辑
|
|
||||||
src/services/video_service.rs → 视频任务调度
|
|
||||||
src/services/usage_service.rs → 用量统计
|
|
||||||
src/services/comfy_client.rs → ComfyUI API 客户端
|
|
||||||
src/workers/video_worker.rs → 后台视频任务 Worker
|
|
||||||
src/middleware/tenant_context.rs → 租户上下文中间件
|
|
||||||
migrations/0010_tenants.sql
|
|
||||||
migrations/0011_tenant_members.sql
|
|
||||||
migrations/0012_subscriptions.sql
|
|
||||||
migrations/0013_usage_counters.sql
|
|
||||||
migrations/0014_comfy_workflows.sql
|
|
||||||
migrations/0015_video_jobs.sql
|
|
||||||
migrations/0016_assets.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端:**
|
|
||||||
```
|
|
||||||
src/web/pages/VideoAssistant.tsx
|
|
||||||
src/web/pages/VideoJobs.tsx
|
|
||||||
src/web/pages/WorkflowLibrary.tsx
|
|
||||||
src/web/pages/TenantSettings.tsx
|
|
||||||
src/web/pages/TenantMembers.tsx
|
|
||||||
src/web/components/video/*.tsx
|
|
||||||
src/web/components/tenant/*.tsx
|
|
||||||
src/web/hooks/useVideoJobs.ts
|
|
||||||
src/web/hooks/useTenant.ts
|
|
||||||
src/web/hooks/useAssets.ts
|
|
||||||
src/web/hooks/useUsage.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
```
|
|
||||||
src/routes/mod.rs → 注册新路由
|
|
||||||
src/web/App.tsx → 添加导航项和路由
|
|
||||||
src/routes/video_agent.rs → 增强现有 Agent SSE
|
|
||||||
src/services/mod.rs → 注册新服务
|
|
||||||
src/config.rs → 添加 ComfyUI/套餐配置
|
|
||||||
src/main.rs → 注册 Worker
|
|
||||||
src/db/mod.rs → 新增表类型
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 实施步骤(按优先级)
|
|
||||||
|
|
||||||
### P0 - 基础链路
|
|
||||||
1. migrations: tenants + video_jobs + assets 三张核心表
|
|
||||||
2. 租户上下文中间件
|
|
||||||
3. 视频助手 Agent SSE 增强 → 脚本解析 + AI 生成调用
|
|
||||||
4. 前端 VideoAssistant 页面(脚本输入→解析→预览→导出)
|
|
||||||
5. ComfyUI client → 提交 prompt + 获取结果
|
|
||||||
|
|
||||||
### P1 - 多租户
|
|
||||||
6. 租户 CRUD + 成员管理 + 邀请
|
|
||||||
7. 资源 tenant_id 隔离
|
|
||||||
8. 用量统计 + 套餐限制
|
|
||||||
9. API Key 管理
|
|
||||||
10. 顶栏租户切换 + 用量显示
|
|
||||||
|
|
||||||
### P2 - 生产增强
|
|
||||||
11. SSE 实时进度
|
|
||||||
12. Worker 后台任务队列
|
|
||||||
13. 视频任务历史 + 重试
|
|
||||||
14. ComfyUI 工作流模板库
|
|
||||||
15. AI prompt 优化(调用 gpt-5.5)
|
|
||||||
|
|
||||||
### P3 - 商业化
|
|
||||||
16. 订阅管理(Stripe/内部)
|
|
||||||
17. 多 ComfyUI 节点调度
|
|
||||||
18. 对象存储集成
|
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build the management console (vite) and the public website (next) in
|
||||||
|
// parallel. Each child inherits stdio with a prefix so logs stay readable.
|
||||||
|
// Exits non-zero if any build fails. Does NOT build the Rust backend.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/** Run a command, prefixing each output line with [label]. */
|
||||||
|
function run(label, cmd, args, cwd) {
|
||||||
|
return new Promise((resolveJob) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const child = spawn(cmd, args, { cwd, shell: false });
|
||||||
|
const pipe = (stream, sink) => {
|
||||||
|
let buf = '';
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop() ?? '';
|
||||||
|
for (const line of lines) sink.write(`[${label}] ${line}\n`);
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (buf) sink.write(`[${label}] ${buf}\n`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pipe(child.stdout, process.stdout);
|
||||||
|
pipe(child.stderr, process.stderr);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
process.stdout.write(`[${label}] ${code === 0 ? '✓ 完成' : '✗ 失败'}(${secs}s)\n`);
|
||||||
|
resolveJob({ label, code: code ?? 1 });
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
process.stderr.write(`[${label}] 启动失败: ${err.message}\n`);
|
||||||
|
resolveJob({ label, code: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
run('web', 'npx', ['vite', 'build'], root),
|
||||||
|
run('website', 'npm', ['--prefix', 'website', 'run', 'build'], root),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(jobs);
|
||||||
|
const failed = results.filter((r) => r.code !== 0);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
process.stderr.write(`\n构建失败: ${failed.map((r) => r.label).join(', ')}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write('\n前端 + 官网构建完成。\n');
|
||||||
@@ -98,6 +98,10 @@ pub struct PixelleVideoConfig {
|
|||||||
pub video_fps: i64,
|
pub video_fps: i64,
|
||||||
pub poll_interval_secs: u64,
|
pub poll_interval_secs: u64,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
|
#[serde(default = "default_pixelle_config_path")]
|
||||||
|
pub config_path: String,
|
||||||
|
#[serde(default = "default_pixelle_compose_dir")]
|
||||||
|
pub compose_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -165,6 +169,14 @@ fn default_pixelle_backend_provider() -> String {
|
|||||||
"comfy".to_string()
|
"comfy".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_pixelle_config_path() -> String {
|
||||||
|
"/opt/Pixelle-Video/config.yaml".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_pixelle_compose_dir() -> String {
|
||||||
|
"/opt/Pixelle-Video".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_workflow_points_cost() -> f64 {
|
fn default_workflow_points_cost() -> f64 {
|
||||||
10.0
|
10.0
|
||||||
}
|
}
|
||||||
@@ -217,6 +229,8 @@ impl Default for PixelleVideoConfig {
|
|||||||
video_fps: 30,
|
video_fps: 30,
|
||||||
poll_interval_secs: 5,
|
poll_interval_secs: 5,
|
||||||
timeout_secs: 900,
|
timeout_secs: 900,
|
||||||
|
config_path: default_pixelle_config_path(),
|
||||||
|
compose_dir: default_pixelle_compose_dir(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -57,9 +57,15 @@ impl AppState {
|
|||||||
reqwest::Client::new()
|
reqwest::Client::new()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let db = Arc::new(db);
|
||||||
|
// Hand the payment module a process-wide DB handle so the Comfy Firebase
|
||||||
|
// refresh path can persist a rotated refresh token without threading
|
||||||
|
// &SqlitePool through every balance/checkout call site.
|
||||||
|
services::payment::init_payment_db(db.clone());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db,
|
||||||
http,
|
http,
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
|
|||||||
+4
-6
@@ -144,12 +144,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let builtin = builtin_keys::BuiltinKeys {
|
let builtin = builtin_keys::BuiltinKeys {
|
||||||
comfyui_agent: comfyui_agent_key,
|
comfyui_agent: comfyui_agent_key,
|
||||||
};
|
};
|
||||||
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
|
let runtime_auth =
|
||||||
&pool,
|
runtime_auth::RuntimeAuthSettings::load(&pool, config.auth.proxy_token.clone())
|
||||||
config.auth.proxy_token.clone(),
|
.await
|
||||||
)
|
.context("failed to load runtime auth settings")?;
|
||||||
.await
|
|
||||||
.context("failed to load runtime auth settings")?;
|
|
||||||
|
|
||||||
// Build app state
|
// Build app state
|
||||||
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod templates;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum CliProfileKind {
|
pub enum CliProfileKind {
|
||||||
Codex,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+334
-35
@@ -589,7 +589,8 @@ pub async fn execute_video_generation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
||||||
route.base_url
|
route
|
||||||
|
.base_url
|
||||||
.strip_prefix("comfy://")
|
.strip_prefix("comfy://")
|
||||||
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
||||||
.or_else(|| route.model.strip_prefix("comfy://"))
|
.or_else(|| route.model.strip_prefix("comfy://"))
|
||||||
@@ -614,14 +615,16 @@ async fn execute_comfy_video_generation(
|
|||||||
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
||||||
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
|
let mut prompt = workflow_prompt_from_graph(&workflow.graph).ok_or_else(|| {
|
||||||
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
|
AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into())
|
||||||
|
})?;
|
||||||
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
let mut mapping =
|
||||||
|
comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
||||||
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
||||||
mapping = infer_comfy_mapping(&prompt);
|
mapping = infer_comfy_mapping(&prompt);
|
||||||
}
|
}
|
||||||
@@ -644,7 +647,9 @@ async fn execute_comfy_video_generation(
|
|||||||
.or_else(|| response.pointer("/data/prompt_id"))
|
.or_else(|| response.pointer("/data/prompt_id"))
|
||||||
.or_else(|| response.pointer("/data/promptId")),
|
.or_else(|| response.pointer("/data/promptId")),
|
||||||
)
|
)
|
||||||
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"id": prompt_id,
|
"id": prompt_id,
|
||||||
@@ -732,7 +737,8 @@ fn scalar_string(value: Option<&Value>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
||||||
graph.get("prompt")
|
graph
|
||||||
|
.get("prompt")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| graph.get("workflow").cloned())
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
@@ -741,7 +747,8 @@ fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
|
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
|
||||||
graph.pointer("/metadata/moneyPrinter/nodeMapping")
|
graph
|
||||||
|
.pointer("/metadata/moneyPrinter/nodeMapping")
|
||||||
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
|
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
|
||||||
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
|
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
|
||||||
.or_else(|| graph.pointer("/video/nodeMapping"))
|
.or_else(|| graph.pointer("/video/nodeMapping"))
|
||||||
@@ -772,11 +779,24 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
||||||
}
|
}
|
||||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
|
for edge in graph
|
||||||
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
|
.get("edges")
|
||||||
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
|
.and_then(Value::as_array)
|
||||||
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
|
.into_iter()
|
||||||
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(from) = edge.get("from").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(to) = edge.get("to").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((from_id, _from_port)) = from.split_once('.') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((to_id, to_field)) = to.split_once('.') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if let Some(inputs) = prompt
|
if let Some(inputs) = prompt
|
||||||
.get_mut(to_id)
|
.get_mut(to_id)
|
||||||
.and_then(Value::as_object_mut)
|
.and_then(Value::as_object_mut)
|
||||||
@@ -806,7 +826,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
|||||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||||
mapping_value(mapping, keys).and_then(|value| {
|
mapping_value(mapping, keys).and_then(|value| {
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
|
scalar_string(
|
||||||
|
value
|
||||||
|
.get("nodeId")
|
||||||
|
.or_else(|| value.get("node_id"))
|
||||||
|
.or(Some(value)),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
scalar_string(Some(value))
|
scalar_string(Some(value))
|
||||||
}
|
}
|
||||||
@@ -829,7 +854,10 @@ fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
|
|||||||
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
|
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
|
fn first_node_by_class(
|
||||||
|
prompt: &serde_json::Map<String, Value>,
|
||||||
|
class_type: &str,
|
||||||
|
) -> Option<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||||
@@ -843,11 +871,22 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
let prompt_node = object
|
let prompt_node = object
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| {
|
.find(|(_, node)| {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
let class_type = node
|
||||||
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
let inputs = node
|
let inputs = node
|
||||||
.get("inputs")
|
.get("inputs")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
class_type.contains("text")
|
class_type.contains("text")
|
||||||
|| class_type.contains("prompt")
|
|| class_type.contains("prompt")
|
||||||
@@ -875,19 +914,29 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
|
|
||||||
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
||||||
if node_id.trim().is_empty() {
|
if node_id.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Comfy 视频模型节点映射包含空 nodeId".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let Some(node) = prompt.get_mut(node_id) else {
|
let Some(node) = prompt.get_mut(node_id) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy prompt missing node {node_id}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy node {node_id} has no inputs"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
inputs.insert(field.to_string(), value);
|
inputs.insert(field.to_string(), value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
|
fn inject_comfy_video_inputs(
|
||||||
|
prompt: &mut Value,
|
||||||
|
mapping: &Value,
|
||||||
|
body: &Value,
|
||||||
|
) -> AppResult<String> {
|
||||||
let video_prompt = scalar_string(body.get("prompt"))
|
let video_prompt = scalar_string(body.get("prompt"))
|
||||||
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
||||||
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
||||||
@@ -895,27 +944,56 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value)
|
|||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&prompt_node,
|
&prompt_node,
|
||||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["prompt", "positive", "text", "promptNode"],
|
||||||
|
"prompt",
|
||||||
|
),
|
||||||
json!(video_prompt),
|
json!(video_prompt),
|
||||||
)?;
|
)?;
|
||||||
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
|
if let Some(negative_node) =
|
||||||
|
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
|
||||||
|
{
|
||||||
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
|
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
|
||||||
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
|
.unwrap_or_else(|| {
|
||||||
|
"low quality, blurry, distorted, watermark, text artifacts".to_string()
|
||||||
|
});
|
||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&negative_node,
|
&negative_node,
|
||||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["negativePrompt", "negative", "negativeNode"],
|
||||||
|
"text",
|
||||||
|
),
|
||||||
json!(negative),
|
json!(negative),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
for (keys, request_keys, default_field) in [
|
for (keys, request_keys, default_field) in [
|
||||||
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
|
(
|
||||||
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
|
&["ratio", "ratioNode"][..],
|
||||||
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
|
&["ratio", "aspectRatio", "aspect_ratio"][..],
|
||||||
|
"ratio",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["resolution", "resolutionNode"][..],
|
||||||
|
&["resolution", "size"][..],
|
||||||
|
"resolution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["duration", "durationNode"][..],
|
||||||
|
&["duration"][..],
|
||||||
|
"duration",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
||||||
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
|
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
|
||||||
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
|
apply_node_input(
|
||||||
|
prompt,
|
||||||
|
&node_id,
|
||||||
|
&mapping_field(mapping, keys, default_field),
|
||||||
|
value,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -929,9 +1007,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
|||||||
.get("class_type")
|
.get("class_type")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.is_some_and(|value| value == class_type)
|
.is_some_and(|value| value == class_type)
|
||||||
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|
|| object
|
||||||
|
.values()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type))
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type)),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1106,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
status.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Some(
|
return Some(
|
||||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
.or_else(|| scalar_string(value.get("error")))
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
@@ -1038,10 +1123,12 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
fn comfy_output_objects(value: &Value) -> bool {
|
fn comfy_output_objects(value: &Value) -> bool {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(object) => {
|
Value::Object(object) => {
|
||||||
["videos", "gifs", "images"]
|
["videos", "gifs", "images"].iter().any(|key| {
|
||||||
.iter()
|
object
|
||||||
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|
.get(*key)
|
||||||
|| object.values().any(comfy_output_objects)
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| !items.is_empty())
|
||||||
|
}) || object.values().any(comfy_output_objects)
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(comfy_output_objects),
|
Value::Array(items) => items.iter().any(comfy_output_objects),
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -1159,3 +1246,215 @@ pub async fn execute_image_edit(
|
|||||||
|
|
||||||
Ok(json)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -305,8 +305,7 @@ fn account_response_value(
|
|||||||
route_id,
|
route_id,
|
||||||
channel_id,
|
channel_id,
|
||||||
log_id,
|
log_id,
|
||||||
)) =
|
)) = runtime_health
|
||||||
runtime_health
|
|
||||||
{
|
{
|
||||||
let ok = status == "success";
|
let ok = status == "success";
|
||||||
let is_degraded_latency =
|
let is_degraded_latency =
|
||||||
@@ -314,7 +313,11 @@ fn account_response_value(
|
|||||||
let model = model_actual
|
let model = model_actual
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
|
.or_else(|| {
|
||||||
|
model_requested
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
});
|
||||||
json!({
|
json!({
|
||||||
"state": if !ok {
|
"state": if !ok {
|
||||||
"unhealthy"
|
"unhealthy"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-34
@@ -20,7 +20,7 @@ use parking_lot::RwLock;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
|
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
|
||||||
Lazy::new(|| RwLock::new(HashMap::new()));
|
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
@@ -1242,6 +1242,14 @@ async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value
|
|||||||
false,
|
false,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
body.insert(
|
||||||
|
"registrationDisabled".to_string(),
|
||||||
|
json!(setting_bool(
|
||||||
|
&settings,
|
||||||
|
"runtime.registration_disabled",
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
);
|
||||||
body.insert(
|
body.insert(
|
||||||
"codexUpstreamWebsocketEnabled".to_string(),
|
"codexUpstreamWebsocketEnabled".to_string(),
|
||||||
json!(setting_bool(
|
json!(setting_bool(
|
||||||
@@ -1541,6 +1549,7 @@ struct RuntimeSettingsUpdateRequest {
|
|||||||
system_proxy_url: Option<String>,
|
system_proxy_url: Option<String>,
|
||||||
payload_rules: Option<Value>,
|
payload_rules: Option<Value>,
|
||||||
model_availability_probe_enabled: Option<bool>,
|
model_availability_probe_enabled: Option<bool>,
|
||||||
|
registration_disabled: Option<bool>,
|
||||||
codex_upstream_websocket_enabled: Option<bool>,
|
codex_upstream_websocket_enabled: Option<bool>,
|
||||||
responses_compact_fallback_to_responses_enabled: Option<bool>,
|
responses_compact_fallback_to_responses_enabled: Option<bool>,
|
||||||
disable_cross_protocol_fallback: Option<bool>,
|
disable_cross_protocol_fallback: Option<bool>,
|
||||||
@@ -1624,6 +1633,14 @@ async fn update_runtime_settings(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
if let Some(disabled) = req.registration_disabled {
|
||||||
|
upsert_setting(
|
||||||
|
&state,
|
||||||
|
"runtime.registration_disabled",
|
||||||
|
bool_setting_value(disabled),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
if let Some(enabled) = req.codex_upstream_websocket_enabled {
|
if let Some(enabled) = req.codex_upstream_websocket_enabled {
|
||||||
upsert_setting(
|
upsert_setting(
|
||||||
&state,
|
&state,
|
||||||
@@ -1870,8 +1887,102 @@ async fn update_runtime_settings(
|
|||||||
Ok(Json(body))
|
Ok(Json(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brand_list() -> Json<Value> {
|
async fn brand_list(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
|
let mut brands = BTreeSet::new();
|
||||||
|
for model in load_model_brand_source_names(&state).await? {
|
||||||
|
if let Some(brand) = infer_model_brand(&model) {
|
||||||
|
brands.insert(brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"brands": brands.into_iter().collect::<Vec<_>>(),
|
||||||
|
"current": Value::Null,
|
||||||
|
"scope": "model-brand",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_model_brand_source_names(state: &AppState) -> AppResult<Vec<String>> {
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT model_name AS model FROM model_availability
|
||||||
|
UNION
|
||||||
|
SELECT model_name AS model FROM token_model_availability
|
||||||
|
UNION
|
||||||
|
SELECT model_pattern AS model FROM token_routes
|
||||||
|
UNION
|
||||||
|
SELECT COALESCE(model_mapping, '') AS model FROM token_routes
|
||||||
|
UNION
|
||||||
|
SELECT COALESCE(source_model, '') AS model FROM route_channels
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(model,)| model.trim().to_string())
|
||||||
|
.filter(|model| !model.is_empty() && !model.contains('*'))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_model_brand(model: &str) -> Option<String> {
|
||||||
|
let normalized = model.trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = normalized.to_ascii_lowercase();
|
||||||
|
let brand = if lower.starts_with("openai/")
|
||||||
|
|| lower.starts_with("gpt-")
|
||||||
|
|| lower.starts_with("o1")
|
||||||
|
|| lower.starts_with("o3")
|
||||||
|
|| lower.starts_with("o4")
|
||||||
|
{
|
||||||
|
"OpenAI"
|
||||||
|
} else if lower.starts_with("anthropic/") || lower.starts_with("claude") {
|
||||||
|
"Anthropic"
|
||||||
|
} else if lower.starts_with("google/") || lower.starts_with("gemini") {
|
||||||
|
"Google"
|
||||||
|
} else if lower.starts_with("deepseek") || lower.starts_with("deepseek-ai/") {
|
||||||
|
"DeepSeek"
|
||||||
|
} else if lower.starts_with("qwen")
|
||||||
|
|| lower.starts_with("qwen/")
|
||||||
|
|| lower.starts_with("alibaba/")
|
||||||
|
|| lower.starts_with("alibaba-cloud/")
|
||||||
|
{
|
||||||
|
"Qwen"
|
||||||
|
} else if lower.starts_with("glm")
|
||||||
|
|| lower.starts_with("zhipu")
|
||||||
|
|| lower.starts_with("bigmodel/")
|
||||||
|
{
|
||||||
|
"GLM"
|
||||||
|
} else if lower.starts_with("moonshot") || lower.starts_with("kimi") {
|
||||||
|
"Moonshot"
|
||||||
|
} else if lower.starts_with("minimax") || lower.starts_with("abab") {
|
||||||
|
"MiniMax"
|
||||||
|
} else if lower.starts_with("doubao")
|
||||||
|
|| lower.starts_with("seedance")
|
||||||
|
|| lower.starts_with("seedream")
|
||||||
|
|| lower.starts_with("volcengine/")
|
||||||
|
|| lower.starts_with("bytedance/")
|
||||||
|
{
|
||||||
|
"Doubao"
|
||||||
|
} else if lower.starts_with("meta-llama/") || lower.starts_with("llama") {
|
||||||
|
"Llama"
|
||||||
|
} else if lower.starts_with("mistral") || lower.starts_with("mixtral") {
|
||||||
|
"Mistral"
|
||||||
|
} else if lower.starts_with("x-ai/") || lower.starts_with("grok") {
|
||||||
|
"xAI"
|
||||||
|
} else if let Some((prefix, _)) = normalized.split_once('/') {
|
||||||
|
let prefix = prefix.trim();
|
||||||
|
if prefix.eq_ignore_ascii_case("boosapi") || prefix.eq_ignore_ascii_case("comfy") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(prefix.to_string());
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(brand.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_system_proxy() -> Json<Value> {
|
async fn test_system_proxy() -> Json<Value> {
|
||||||
@@ -1952,7 +2063,9 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !normalized.starts_with('/') {
|
if !normalized.starts_with('/') {
|
||||||
return Err(AppError::BadRequest("proxy test path must start with /".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"proxy test path must start with /".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let base_path = proxy_test_base_path(normalized);
|
let base_path = proxy_test_base_path(normalized);
|
||||||
let allowed = [
|
let allowed = [
|
||||||
@@ -1968,7 +2081,8 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
|||||||
"/v1/videos",
|
"/v1/videos",
|
||||||
];
|
];
|
||||||
let allowed_exact = allowed.iter().any(|item| base_path == *item);
|
let allowed_exact = allowed.iter().any(|item| base_path == *item);
|
||||||
let allowed_video_task = base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
|
let allowed_video_task =
|
||||||
|
base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
|
||||||
let allowed_gemini = is_gemini_proxy_test_path(normalized);
|
let allowed_gemini = is_gemini_proxy_test_path(normalized);
|
||||||
if allowed_exact || allowed_video_task || allowed_gemini {
|
if allowed_exact || allowed_video_task || allowed_gemini {
|
||||||
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
|
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
|
||||||
@@ -1988,9 +2102,9 @@ fn path_suffix_for_proxy_test(path: &str) -> AppResult<String> {
|
|||||||
if is_gemini_proxy_test_path(without_query) {
|
if is_gemini_proxy_test_path(without_query) {
|
||||||
return Ok(without_query.trim_start_matches('/').to_string());
|
return Ok(without_query.trim_start_matches('/').to_string());
|
||||||
}
|
}
|
||||||
let suffix = without_query
|
let suffix = without_query.strip_prefix("/v1").ok_or_else(|| {
|
||||||
.strip_prefix("/v1")
|
AppError::BadRequest(format!("unsupported proxy test path: {without_query}"))
|
||||||
.ok_or_else(|| AppError::BadRequest(format!("unsupported proxy test path: {without_query}")))?;
|
})?;
|
||||||
Ok(if suffix.is_empty() {
|
Ok(if suffix.is_empty() {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -2089,7 +2203,9 @@ fn native_gemini_proxy_test_url(base_url: &str, path: &str) -> AppResult<String>
|
|||||||
let normalized = normalize_proxy_test_path(path)?;
|
let normalized = normalize_proxy_test_path(path)?;
|
||||||
let trimmed_base = base_url.trim().trim_end_matches('/');
|
let trimmed_base = base_url.trim().trim_end_matches('/');
|
||||||
if trimmed_base.is_empty() {
|
if trimmed_base.is_empty() {
|
||||||
return Err(AppError::BadRequest("Gemini upstream base URL is empty".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Gemini upstream base URL is empty".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
|
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
|
||||||
trimmed_base.to_string()
|
trimmed_base.to_string()
|
||||||
@@ -2281,7 +2397,10 @@ async fn execute_proxy_test(
|
|||||||
) -> Result<Value, Value> {
|
) -> Result<Value, Value> {
|
||||||
if envelope.request_kind == "multipart"
|
if envelope.request_kind == "multipart"
|
||||||
|| envelope.multipart_fields.is_some()
|
|| envelope.multipart_fields.is_some()
|
||||||
|| envelope.multipart_files.as_ref().is_some_and(|files| !files.is_empty())
|
|| envelope
|
||||||
|
.multipart_files
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|files| !files.is_empty())
|
||||||
{
|
{
|
||||||
return Err(error_json_for_proxy_test(
|
return Err(error_json_for_proxy_test(
|
||||||
&envelope,
|
&envelope,
|
||||||
@@ -2403,11 +2522,9 @@ async fn stream_proxy_test(
|
|||||||
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
|
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let stream = response.bytes_stream().map(|chunk| {
|
let stream = response
|
||||||
chunk
|
.bytes_stream()
|
||||||
.map_err(std::io::Error::other)
|
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||||
.map(bytes::Bytes::from)
|
|
||||||
});
|
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||||
@@ -2441,11 +2558,9 @@ async fn stream_proxy_test(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let stream = response.bytes_stream().map(|chunk| {
|
let stream = response
|
||||||
chunk
|
.bytes_stream()
|
||||||
.map_err(std::io::Error::other)
|
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||||
.map(bytes::Bytes::from)
|
|
||||||
});
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||||
@@ -2840,6 +2955,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -2852,8 +2969,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
|
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent")
|
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent").unwrap(),
|
||||||
.unwrap(),
|
|
||||||
"/v1beta/models/gemini-2.5-pro:generateContent"
|
"/v1beta/models/gemini-2.5-pro:generateContent"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2981,12 +3097,10 @@ mod tests {
|
|||||||
let error = result.expect_err("unknown paths should fail");
|
let error = result.expect_err("unknown paths should fail");
|
||||||
|
|
||||||
assert_eq!(error["path"], "/api/test/proxy/jobs");
|
assert_eq!(error["path"], "/api/test/proxy/jobs");
|
||||||
assert!(
|
assert!(error["message"]
|
||||||
error["message"]
|
.as_str()
|
||||||
.as_str()
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.contains("unsupported proxy test path"));
|
||||||
.contains("unsupported proxy test path")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3058,12 +3172,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(error["path"], "/v1/chat/completions");
|
assert_eq!(error["path"], "/v1/chat/completions");
|
||||||
assert_eq!(error["forcedChannelId"], 77);
|
assert_eq!(error["forcedChannelId"], 77);
|
||||||
assert!(
|
assert!(error["message"]
|
||||||
error["message"]
|
.as_str()
|
||||||
.as_str()
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.contains("Forced channel #77"));
|
||||||
.contains("Forced channel #77")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3130,4 +3242,25 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result["model"], "upstream-mini");
|
assert_eq!(result["model"], "upstream-mini");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_model_brands_without_boosapi_platform_brand() {
|
||||||
|
assert_eq!(infer_model_brand("gpt-4o-mini").as_deref(), Some("OpenAI"));
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("claude-3-5-sonnet").as_deref(),
|
||||||
|
Some("Anthropic")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("deepseek-ai/DeepSeek-V3.2").as_deref(),
|
||||||
|
Some("DeepSeek")
|
||||||
|
);
|
||||||
|
assert_eq!(infer_model_brand("Qwen/Qwen3-32B").as_deref(), Some("Qwen"));
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("seedance-1-0-lite-t2v").as_deref(),
|
||||||
|
Some("Doubao")
|
||||||
|
);
|
||||||
|
assert_eq!(infer_model_brand("BoosAPI").as_deref(), None);
|
||||||
|
assert_eq!(infer_model_brand("boosapi/text-to-image").as_deref(), None);
|
||||||
|
assert_eq!(infer_model_brand("comfy/template").as_deref(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ use tracing::info;
|
|||||||
|
|
||||||
mod account_tokens;
|
mod account_tokens;
|
||||||
mod accounts;
|
mod accounts;
|
||||||
|
mod cli_templates;
|
||||||
mod compat;
|
mod compat;
|
||||||
mod downstream_keys;
|
mod downstream_keys;
|
||||||
mod model_candidates;
|
mod model_candidates;
|
||||||
pub(crate) mod models_marketplace;
|
pub(crate) mod models_marketplace;
|
||||||
|
mod pixelle_connection;
|
||||||
mod sites;
|
mod sites;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
mod users;
|
mod users;
|
||||||
@@ -174,6 +176,11 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
get(models_marketplace::get_marketplace),
|
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()
|
let users_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/users",
|
"/api/admin/users",
|
||||||
@@ -191,6 +198,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
"/api/admin/user-balances",
|
"/api/admin/user-balances",
|
||||||
get(users::list_user_balances),
|
get(users::list_user_balances),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/pixelle/connection",
|
||||||
|
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/users/{id}/balance/adjust",
|
"/api/admin/users/{id}/balance/adjust",
|
||||||
post(users::adjust_user_balance),
|
post(users::adjust_user_balance),
|
||||||
@@ -221,6 +232,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
.merge(users_routes)
|
.merge(users_routes)
|
||||||
.merge(model_candidates_routes)
|
.merge(model_candidates_routes)
|
||||||
.merge(models_marketplace_routes)
|
.merge(models_marketplace_routes)
|
||||||
|
.merge(cli_templates_routes)
|
||||||
.merge(account_tokens::routes())
|
.merge(account_tokens::routes())
|
||||||
.merge(compat::routes())
|
.merge(compat::routes())
|
||||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::services::pixelle_connection::{self, PixelleConnection};
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const MASK_SENTINEL: &str = "••••";
|
||||||
|
|
||||||
|
fn mask(v: &Option<String>) -> Value {
|
||||||
|
match v {
|
||||||
|
Some(s) if s.chars().count() > 4 => {
|
||||||
|
let last4: String = s.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
|
||||||
|
json!(format!("{}{}", MASK_SENTINEL, last4))
|
||||||
|
}
|
||||||
|
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
|
||||||
|
_ => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
let c = pixelle_connection::read_connection(&state.db).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"connection": {
|
||||||
|
"comfyuiUrl": c.comfyui_url,
|
||||||
|
"runninghubApiKey": mask(&c.runninghub_api_key),
|
||||||
|
"comfyuiApiKey": mask(&c.comfyui_api_key),
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdatePayload {
|
||||||
|
runninghub_api_key: Option<String>,
|
||||||
|
comfyui_url: Option<String>,
|
||||||
|
comfyui_api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
|
||||||
|
match incoming {
|
||||||
|
Some(s) if s.starts_with(MASK_SENTINEL) => None,
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_connection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(p): Json<UpdatePayload>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let conn = PixelleConnection {
|
||||||
|
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
|
||||||
|
comfyui_url: p.comfyui_url,
|
||||||
|
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
|
||||||
|
};
|
||||||
|
pixelle_connection::save_connection(&state.db, &conn).await?;
|
||||||
|
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
|
||||||
|
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||||
|
match restart {
|
||||||
|
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
|
||||||
|
Err(e) => Ok(Json(json!({
|
||||||
|
"success": true, "backup": backup, "restarted": false,
|
||||||
|
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
|
||||||
|
}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-18
@@ -913,9 +913,14 @@ async fn probe_site_model(
|
|||||||
|
|
||||||
match response {
|
match response {
|
||||||
Ok(_) if latency_exceeded => {
|
Ok(_) if latency_exceeded => {
|
||||||
if let Err(err) =
|
if let Err(err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
state,
|
||||||
.await
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||||
}
|
}
|
||||||
@@ -924,7 +929,10 @@ async fn probe_site_model(
|
|||||||
status: "unsupported".to_string(),
|
status: "unsupported".to_string(),
|
||||||
latency_ms: Some(latency_ms),
|
latency_ms: Some(latency_ms),
|
||||||
latency_exceeded: true,
|
latency_exceeded: true,
|
||||||
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
|
reason: Some(format!(
|
||||||
|
"响应延迟 {}ms 超过阈值 {}ms",
|
||||||
|
latency_ms, plan.latency_threshold_ms
|
||||||
|
)),
|
||||||
http_status: None,
|
http_status: None,
|
||||||
stage: Some("model"),
|
stage: Some("model"),
|
||||||
source: Some("probe"),
|
source: Some("probe"),
|
||||||
@@ -947,9 +955,14 @@ async fn probe_site_model(
|
|||||||
warn!(channel_id, error = %err, "failed to record probe success");
|
warn!(channel_id, error = %err, "failed to record probe success");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(err) =
|
if let Err(err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
state,
|
||||||
.await
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||||
}
|
}
|
||||||
@@ -982,9 +995,14 @@ async fn probe_site_model(
|
|||||||
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(update_err) =
|
if let Err(update_err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
|
state,
|
||||||
.await
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
||||||
}
|
}
|
||||||
@@ -1041,7 +1059,8 @@ async fn resolve_probe_channel(
|
|||||||
.bind(plan.site.id)
|
.bind(plan.site.id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
fallback
|
||||||
|
.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1067,7 +1086,9 @@ async fn resolve_probe_channel(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if api_key.trim().is_empty() {
|
if api_key.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"当前站点账号缺少可用 Token,无法探测".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(crate::proxy::conductor::SelectedChannel {
|
Ok(crate::proxy::conductor::SelectedChannel {
|
||||||
@@ -1165,8 +1186,10 @@ fn send_probe_sse(
|
|||||||
tx.send(Ok(Event::default()
|
tx.send(Ok(Event::default()
|
||||||
.event(event_type)
|
.event(event_type)
|
||||||
.json_data(payload)
|
.json_data(payload)
|
||||||
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
|
.unwrap_or_else(|_| {
|
||||||
.is_ok()
|
Event::default().event(event_type).data("{}")
|
||||||
|
})))
|
||||||
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn probe_error_message(err: &AppError) -> String {
|
fn probe_error_message(err: &AppError) -> String {
|
||||||
@@ -1386,6 +1409,9 @@ fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
|||||||
{
|
{
|
||||||
return ("openai", Some("volcengine-ark-openai"));
|
return ("openai", Some("volcengine-ark-openai"));
|
||||||
}
|
}
|
||||||
|
if lower.starts_with("https://cloud.comfy.org") {
|
||||||
|
return ("comfy_cloud", Some("comfy-cloud"));
|
||||||
|
}
|
||||||
|
|
||||||
if lower.contains("anthropic") || lower.contains("claude") {
|
if lower.contains("anthropic") || lower.contains("claude") {
|
||||||
return ("claude", None);
|
return ("claude", None);
|
||||||
@@ -1516,8 +1542,14 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
assert_eq!(
|
||||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
payload.get("source").and_then(Value::as_str),
|
||||||
|
Some("upstream")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("modelName").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1528,9 +1560,15 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
assert_eq!(
|
||||||
|
payload.get("source").and_then(Value::as_str),
|
||||||
|
Some("upstream")
|
||||||
|
);
|
||||||
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
|
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
|
||||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
assert_eq!(
|
||||||
|
payload.get("modelName").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload.get("message").and_then(Value::as_str),
|
payload.get("message").and_then(Value::as_str),
|
||||||
Some("上游返回 HTTP 404 Not Found:model missing")
|
Some("上游返回 HTTP 404 Not Found:model missing")
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
+209
-166
@@ -19,6 +19,11 @@ use serde_json::{json, Value};
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// 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 ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -108,6 +113,21 @@ async fn register(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Registering user: {}", payload.email);
|
info!("Registering user: {}", payload.email);
|
||||||
|
|
||||||
|
// Admin can disable self-service registration from 系统设置.
|
||||||
|
let registration_disabled: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'runtime.registration_disabled'")
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
if registration_disabled
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| matches!(v.trim(), "true" | "1"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"用户注册已被管理员关闭".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"email and username are required".into(),
|
"email and username are required".into(),
|
||||||
@@ -177,11 +197,12 @@ async fn register(
|
|||||||
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||||
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, 1, ?)",
|
||||||
)
|
)
|
||||||
.bind(&default_key_name)
|
.bind(&default_key_name)
|
||||||
.bind(&default_key)
|
.bind(&default_key)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
@@ -237,72 +258,66 @@ async fn login(
|
|||||||
let user_agent = user_agent_from_headers(&headers);
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
let email = payload.email.trim().to_string();
|
let email = payload.email.trim().to_string();
|
||||||
|
|
||||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
let user: User = if let Some(user) =
|
||||||
.bind(&email)
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||||
.fetch_optional(&*state.db)
|
.bind(&email)
|
||||||
.await?
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
user
|
user
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::Password,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: None,
|
method: LoginMethod::Password,
|
||||||
identifier: Some(&email),
|
success: false,
|
||||||
api_key: None,
|
user: None,
|
||||||
client_ip: &client_ip,
|
identifier: Some(&email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: Some("Invalid email or password"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid email or password"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
};
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::Password,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: Some(&user),
|
method: LoginMethod::Password,
|
||||||
identifier: Some(&email),
|
success: false,
|
||||||
api_key: None,
|
user: Some(&user),
|
||||||
client_ip: &client_ip,
|
identifier: Some(&email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: Some("Account is disabled"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Account is disabled"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
if user.role == "admin" {
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
|
||||||
method: LoginMethod::Password,
|
|
||||||
success: false,
|
|
||||||
user: Some(&user),
|
|
||||||
identifier: Some(&email),
|
|
||||||
api_key: None,
|
|
||||||
client_ip: &client_ip,
|
|
||||||
user_agent: user_agent.as_deref(),
|
|
||||||
reason: Some("Admin users must sign in with the admin API token"),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Admin users must sign in with the admin API token".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let valid = verify_password(&payload.password, &user.password_hash)
|
let valid = verify_password(&payload.password, &user.password_hash)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::Password,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: Some(&user),
|
method: LoginMethod::Password,
|
||||||
identifier: Some(&email),
|
success: false,
|
||||||
api_key: None,
|
user: Some(&user),
|
||||||
client_ip: &client_ip,
|
identifier: Some(&email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: Some("Invalid email or password"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid email or password"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
}
|
}
|
||||||
@@ -315,16 +330,19 @@ async fn login(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::Password,
|
&state,
|
||||||
success: true,
|
LoginAuditEvent {
|
||||||
user: Some(&user),
|
method: LoginMethod::Password,
|
||||||
identifier: Some(&email),
|
success: true,
|
||||||
api_key: None,
|
user: Some(&user),
|
||||||
client_ip: &client_ip,
|
identifier: Some(&email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: None,
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -365,16 +383,19 @@ async fn login_api_key(
|
|||||||
let user_agent = user_agent_from_headers(&headers);
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
let key = payload.api_key.trim();
|
let key = payload.api_key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: None,
|
method: LoginMethod::ApiKey,
|
||||||
identifier: None,
|
success: false,
|
||||||
api_key: None,
|
user: None,
|
||||||
client_ip: &client_ip,
|
identifier: None,
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: Some("api_key is required"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("api_key is required"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::BadRequest("api_key is required".into()));
|
return Err(AppError::BadRequest("api_key is required".into()));
|
||||||
}
|
}
|
||||||
@@ -390,87 +411,86 @@ async fn login_api_key(
|
|||||||
api_key
|
api_key
|
||||||
} else {
|
} else {
|
||||||
let masked_key = mask_api_key(key);
|
let masked_key = mask_api_key(key);
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: None,
|
method: LoginMethod::ApiKey,
|
||||||
identifier: Some(masked_key.as_str()),
|
success: false,
|
||||||
api_key: None,
|
user: None,
|
||||||
client_ip: &client_ip,
|
identifier: Some(masked_key.as_str()),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: None,
|
||||||
reason: Some("Invalid API key"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Invalid API key"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid API key".into()));
|
return Err(AppError::Unauthorized("Invalid API key".into()));
|
||||||
};
|
};
|
||||||
let user_id = if let Some(user_id) = api_key.user_id {
|
let user_id = if let Some(user_id) = api_key.user_id {
|
||||||
user_id
|
user_id
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: None,
|
method: LoginMethod::ApiKey,
|
||||||
identifier: Some(&api_key.name),
|
success: false,
|
||||||
api_key: Some(&api_key),
|
user: None,
|
||||||
client_ip: &client_ip,
|
identifier: Some(&api_key.name),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: Some(&api_key),
|
||||||
reason: Some("API key is not bound to a user"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("API key is not bound to a user"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
|
return Err(AppError::Unauthorized(
|
||||||
|
"API key is not bound to a user".into(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
let user: User = if let Some(user) =
|
||||||
.bind(user_id)
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
.fetch_optional(&*state.db)
|
.bind(user_id)
|
||||||
.await?
|
.fetch_optional(&*state.db)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
user
|
user
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: None,
|
method: LoginMethod::ApiKey,
|
||||||
identifier: Some(&api_key.name),
|
success: false,
|
||||||
api_key: Some(&api_key),
|
user: None,
|
||||||
client_ip: &client_ip,
|
identifier: Some(&api_key.name),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: Some(&api_key),
|
||||||
reason: Some("User not found"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("User not found"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("User not found".into()));
|
return Err(AppError::Unauthorized("User not found".into()));
|
||||||
};
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: false,
|
LoginAuditEvent {
|
||||||
user: Some(&user),
|
method: LoginMethod::ApiKey,
|
||||||
identifier: Some(&user.email),
|
success: false,
|
||||||
api_key: Some(&api_key),
|
user: Some(&user),
|
||||||
client_ip: &client_ip,
|
identifier: Some(&user.email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: Some(&api_key),
|
||||||
reason: Some("Account is disabled"),
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: Some("Account is disabled"),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
if user.role == "admin" {
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
|
||||||
method: LoginMethod::ApiKey,
|
|
||||||
success: false,
|
|
||||||
user: Some(&user),
|
|
||||||
identifier: Some(&user.email),
|
|
||||||
api_key: Some(&api_key),
|
|
||||||
client_ip: &client_ip,
|
|
||||||
user_agent: user_agent.as_deref(),
|
|
||||||
reason: Some("Admin users must sign in with the admin API token"),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Admin users must sign in with the admin API token".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Login via API key: user_id={}", user.id);
|
info!("Login via API key: user_id={}", user.id);
|
||||||
|
|
||||||
@@ -482,16 +502,19 @@ async fn login_api_key(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
method: LoginMethod::ApiKey,
|
&state,
|
||||||
success: true,
|
LoginAuditEvent {
|
||||||
user: Some(&user),
|
method: LoginMethod::ApiKey,
|
||||||
identifier: Some(&user.email),
|
success: true,
|
||||||
api_key: Some(&api_key),
|
user: Some(&user),
|
||||||
client_ip: &client_ip,
|
identifier: Some(&user.email),
|
||||||
user_agent: user_agent.as_deref(),
|
api_key: Some(&api_key),
|
||||||
reason: None,
|
client_ip: &client_ip,
|
||||||
})
|
user_agent: user_agent.as_deref(),
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -747,7 +770,11 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
|||||||
.bind(message_parts.join(" · "))
|
.bind(message_parts.join(" · "))
|
||||||
.bind(level)
|
.bind(level)
|
||||||
.bind(audit.user.map(|user| user.id))
|
.bind(audit.user.map(|user| user.id))
|
||||||
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
|
.bind(if audit.user.is_some() {
|
||||||
|
Some("user")
|
||||||
|
} else {
|
||||||
|
None::<&str>
|
||||||
|
})
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -864,17 +891,17 @@ fn normalize_ip_token(raw: &str) -> Option<String> {
|
|||||||
token = host;
|
token = host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
token
|
token.parse::<IpAddr>().map(|ip| ip.to_string()).ok()
|
||||||
.parse::<IpAddr>()
|
|
||||||
.map(|ip| ip.to_string())
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
||||||
if value.chars().count() <= max_chars {
|
if value.chars().count() <= max_chars {
|
||||||
return value.to_string();
|
return value.to_string();
|
||||||
}
|
}
|
||||||
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
|
let mut truncated = value
|
||||||
|
.chars()
|
||||||
|
.take(max_chars.saturating_sub(1))
|
||||||
|
.collect::<String>();
|
||||||
truncated.push('…');
|
truncated.push('…');
|
||||||
truncated
|
truncated
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1045,9 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -1055,7 +1084,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn password_login_rejects_admin_users() {
|
async fn password_login_returns_admin_role_for_admin_users() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("admin-password").expect("password hash");
|
let password_hash = hash_password("admin-password").expect("password hash");
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1069,8 +1098,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert admin user");
|
.expect("insert admin user");
|
||||||
|
|
||||||
let err = login(
|
let Json(value) = login(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginRequest {
|
Json(LoginRequest {
|
||||||
@@ -1079,9 +1108,12 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("admin password login must be rejected");
|
.expect("admin password login should succeed");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1147,7 +1179,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("regular user password login succeeds");
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) =
|
||||||
|
latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
assert!(title.contains("Regular User <audit@example.test>"));
|
assert!(title.contains("Regular User <audit@example.test>"));
|
||||||
@@ -1160,7 +1193,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_key_login_rejects_admin_role() {
|
async fn api_key_login_returns_admin_role_for_admin_user() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("unused-password").expect("password hash");
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
let user_id = sqlx::query(
|
let user_id = sqlx::query(
|
||||||
@@ -1186,8 +1219,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert bound api key");
|
.expect("insert bound api key");
|
||||||
|
|
||||||
let err = login_api_key(
|
let Json(value) = login_api_key(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginApiKeyRequest {
|
Json(LoginApiKeyRequest {
|
||||||
@@ -1195,14 +1228,17 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("admin api key login must be rejected");
|
.expect("admin api key login should succeed");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
assert!(user_id > 0);
|
assert!(user_id > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
|
async fn api_key_login_returns_latest_admin_role_after_promotion() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("unused-password").expect("password hash");
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
let user_id = sqlx::query(
|
let user_id = sqlx::query(
|
||||||
@@ -1234,8 +1270,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("promote user to admin");
|
.expect("promote user to admin");
|
||||||
|
|
||||||
let err = login_api_key(
|
let Json(value) = login_api_key(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginApiKeyRequest {
|
Json(LoginApiKeyRequest {
|
||||||
@@ -1243,9 +1279,12 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("api key login must use latest role and reject admin");
|
.expect("api key login uses latest role and accepts admin");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
assert!(user_id > 0);
|
assert!(user_id > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,7 +1372,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("regular api key login succeeds");
|
.expect("regular api key login succeeds");
|
||||||
|
|
||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) =
|
||||||
|
latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
assert!(title.contains("Regular User <key-audit@example.test>"));
|
assert!(title.contains("Regular User <key-audit@example.test>"));
|
||||||
@@ -1363,7 +1403,10 @@ mod tests {
|
|||||||
let Json(_) = login(
|
let Json(_) = login(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
|
headers_with(
|
||||||
|
"x-forwarded-for",
|
||||||
|
"2a09:bac2:3918:16dc::247:4d, 203.0.113.9",
|
||||||
|
),
|
||||||
Json(LoginRequest {
|
Json(LoginRequest {
|
||||||
email: "ipv4@example.test".into(),
|
email: "ipv4@example.test".into(),
|
||||||
password: "user-password".into(),
|
password: "user-password".into(),
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
|
||||||
|
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Path, RawQuery, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::Response,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/comfy-cloud/prompt", post(submit_prompt))
|
||||||
|
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
|
||||||
|
.route("/comfy-cloud/view", get(get_view))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_prompt(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
|
||||||
|
let resp = client()
|
||||||
|
.post(format!("{base_url}/api/prompt"))
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
|
||||||
|
}
|
||||||
|
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||||
|
let http = client();
|
||||||
|
let status_resp = http
|
||||||
|
.get(format!("{base_url}/api/job/{prompt_id}/status"))
|
||||||
|
.header("X-API-Key", &api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !status_resp.status().is_success() {
|
||||||
|
let st = status_resp.status();
|
||||||
|
let body = status_resp.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
|
||||||
|
}
|
||||||
|
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
|
||||||
|
|
||||||
|
if status != "completed" {
|
||||||
|
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
|
||||||
|
}
|
||||||
|
let detail_resp = http
|
||||||
|
.get(format!("{base_url}/api/jobs/{prompt_id}"))
|
||||||
|
.header("X-API-Key", &api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !detail_resp.status().is_success() {
|
||||||
|
let st = detail_resp.status();
|
||||||
|
let body = detail_resp.text().await.unwrap_or_default();
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud jobs [{st}]: {body}")));
|
||||||
|
}
|
||||||
|
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
let outputs = detail_json.get("outputs");
|
||||||
|
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(prompt_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||||
|
let q = query.unwrap_or("");
|
||||||
|
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
|
||||||
|
let resp = client()
|
||||||
|
.get(url)
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let st = resp.status();
|
||||||
|
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
|
||||||
|
}
|
||||||
|
let mime = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
|
||||||
|
Ok((mime, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_view(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
RawQuery(query): RawQuery,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, mime)
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use wiremock::matchers::{header as match_header, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
|
||||||
|
.bind(base_url).execute(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn submit_forwards_with_api_key() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/api/prompt"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||||
|
let resp = client().post(format!("{base_url}/api/prompt"))
|
||||||
|
.header("X-API-Key", api_key)
|
||||||
|
.json(&serde_json::json!({ "prompt": {} }))
|
||||||
|
.send().await.unwrap();
|
||||||
|
let v: Value = resp.json().await.unwrap();
|
||||||
|
assert_eq!(v["prompt_id"], "p1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_in_progress_then_completed() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/job/p1/status"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/jobs/p1"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||||
|
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
|
||||||
|
})))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||||
|
assert_eq!(history["p1"]["status"]["completed"], true);
|
||||||
|
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_completed_job_detail_non_success_errors() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/job/p1/status"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/jobs/p1"))
|
||||||
|
.respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ "error": "boom" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
assert!(fetch_history(&pool, "p1").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn history_failed_status_maps_to_error() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/job/p1/status"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "failed" })))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||||
|
assert_eq!(history["p1"]["status"]["status_str"], "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn view_proxies_file_bytes() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/api/view"))
|
||||||
|
.and(match_header("X-API-Key", "key-123"))
|
||||||
|
.respond_with(ResponseTemplate::new(200)
|
||||||
|
.insert_header("content-type", "image/png")
|
||||||
|
.set_body_bytes(b"PNGDATA".to_vec()))
|
||||||
|
.mount(&server).await;
|
||||||
|
|
||||||
|
let pool = db_with_site(&server.uri()).await;
|
||||||
|
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
|
||||||
|
assert_eq!(mime, "image/png");
|
||||||
|
assert_eq!(bytes, b"PNGDATA");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,10 +60,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
// Downstream API key
|
// Downstream API key
|
||||||
if auth.starts_with("sk-") {
|
if auth.starts_with("sk-") {
|
||||||
let proxy_token = state.runtime_proxy_token();
|
let proxy_token = state.runtime_proxy_token();
|
||||||
let result =
|
let result = authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||||
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
.await
|
||||||
.await
|
.map_err(AppError::Unauthorized)?;
|
||||||
.map_err(AppError::Unauthorized)?;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
DownstreamAuthResult::Managed { key, .. } => {
|
DownstreamAuthResult::Managed { key, .. } => {
|
||||||
@@ -127,6 +126,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+288
-39
@@ -16,47 +16,30 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
|||||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手(智能体),帮用户通过 AI 制作视频。
|
||||||
|
|
||||||
## 规则
|
## 工作方式
|
||||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定。
|
||||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
2. 用户确认后(说"开始制作"或"生成"),调用工具逐步产出素材。
|
||||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
3. 你拥有以下工具,应主动按需调用,并根据每次工具返回的结果决定下一步:
|
||||||
|
- generate_character_images:为角色生成正/背/左/右四视图参考图
|
||||||
|
- generate_scene_image:生成单张场景/分镜图
|
||||||
|
- generate_tts:为一句台词合成语音
|
||||||
|
- build_comfyui_workflow:在素材就绪后交付最终可执行的 ComfyUI 工作流 JSON
|
||||||
|
|
||||||
## 结构化标记格式
|
## 调用原则
|
||||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
- 一次可调用一个或多个工具;每个角色/场景/台词分别调用对应工具。
|
||||||
|
- 收到工具结果后继续推进,直到所有素材生成完毕,最后调用 build_comfyui_workflow 交付。
|
||||||
|
- 图像 prompt 用英文,包含 主体 + 视角 + 细节 + 画质标签。
|
||||||
|
- 没有用户明确要求时,不要自作主张批量生成;先沟通确认。
|
||||||
|
|
||||||
【CHARACTER】
|
## 兼容降级
|
||||||
name: 林澈
|
若你所在的模型不支持工具调用,可改用结构化标记输出(系统会解析):
|
||||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
【CHARACTER】name/description/prompt_front/prompt_back/prompt_left/prompt_right【/CHARACTER】
|
||||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
【SCENE】id/description/prompt【/SCENE】
|
||||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
【TTS】character/text/emotion【/TTS】
|
||||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
【WORKFLOW】```json {...} ```【/WORKFLOW】
|
||||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
但只要支持工具调用,就优先使用工具,不要输出这些标记。
|
||||||
【/CHARACTER】
|
|
||||||
|
|
||||||
【SCENE】
|
|
||||||
id: 1
|
|
||||||
description: 飞船驾驶舱内景
|
|
||||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
|
||||||
【/SCENE】
|
|
||||||
|
|
||||||
【TTS】
|
|
||||||
character: 林澈
|
|
||||||
text: 我们准备出舱检查
|
|
||||||
emotion: 平静
|
|
||||||
【/TTS】
|
|
||||||
|
|
||||||
【WORKFLOW】
|
|
||||||
```json
|
|
||||||
{"comfyui_workflow": {}}
|
|
||||||
```
|
|
||||||
【/WORKFLOW】
|
|
||||||
|
|
||||||
## 关键要求
|
|
||||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
|
||||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
|
||||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
@@ -683,6 +666,190 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpenAI-style tool definitions advertised to the upstream model so the
|
||||||
|
/// agent can request image / scene / TTS / workflow generation step by step.
|
||||||
|
fn agent_tool_definitions() -> Value {
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_character_images",
|
||||||
|
"description": "为一个角色生成四视图(正/背/左/右)参考图。当需要确定角色形象时调用。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_name": { "type": "string", "description": "角色名称" },
|
||||||
|
"description": { "type": "string", "description": "角色整体描述" },
|
||||||
|
"prompt_front": { "type": "string", "description": "正面视角的图像提示词" },
|
||||||
|
"prompt_back": { "type": "string", "description": "背面视角的图像提示词" },
|
||||||
|
"prompt_left": { "type": "string", "description": "左侧视角的图像提示词" },
|
||||||
|
"prompt_right": { "type": "string", "description": "右侧视角的图像提示词" }
|
||||||
|
},
|
||||||
|
"required": ["character_name", "prompt_front"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_scene_image",
|
||||||
|
"description": "生成一张场景/分镜图。在规划好分镜后逐个调用。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scene_id": { "type": "integer", "description": "场景编号" },
|
||||||
|
"description": { "type": "string", "description": "场景描述" },
|
||||||
|
"prompt": { "type": "string", "description": "场景图像提示词" }
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_tts",
|
||||||
|
"description": "为一句台词生成语音。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character": { "type": "string", "description": "说话角色" },
|
||||||
|
"text": { "type": "string", "description": "台词文本" },
|
||||||
|
"emotion": { "type": "string", "description": "情绪:平静/激动/愤怒/悲伤/恐惧" }
|
||||||
|
},
|
||||||
|
"required": ["text"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "build_comfyui_workflow",
|
||||||
|
"description": "输出最终可执行的 ComfyUI 工作流 JSON。在素材准备完成后调用以交付工作流。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workflow_json": { "type": "string", "description": "完整的 ComfyUI 工作流 JSON 字符串" }
|
||||||
|
},
|
||||||
|
"required": ["workflow_json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an emotion label to an OpenAI TTS voice.
|
||||||
|
fn voice_for_emotion(emotion: &str) -> &'static str {
|
||||||
|
match emotion {
|
||||||
|
"激动" | "愤怒" => "nova",
|
||||||
|
"悲伤" | "恐惧" => "shimmer",
|
||||||
|
_ => "alloy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute one tool call requested by the model. Emits SSE side-effects
|
||||||
|
/// (image/scene/tts/comfyui_workflow events) and returns a short text result
|
||||||
|
/// that is fed back to the model as the tool message content.
|
||||||
|
async fn execute_agent_tool(
|
||||||
|
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
api_base: &str,
|
||||||
|
api_key: &str,
|
||||||
|
image_model: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &Value,
|
||||||
|
) -> String {
|
||||||
|
match tool_name {
|
||||||
|
"generate_character_images" => {
|
||||||
|
let name = args.get("character_name").and_then(Value::as_str).unwrap_or("角色");
|
||||||
|
let views: [(&str, &str); 4] = [
|
||||||
|
("front", "prompt_front"),
|
||||||
|
("back", "prompt_back"),
|
||||||
|
("left", "prompt_left"),
|
||||||
|
("right", "prompt_right"),
|
||||||
|
];
|
||||||
|
let mut ok = 0;
|
||||||
|
for (view, key) in views {
|
||||||
|
let Some(prompt) = args.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成 {} 的 {} 视图", name, view) }));
|
||||||
|
match call_image_gen(api_base, api_key, image_model, prompt).await {
|
||||||
|
Ok(url) if !url.is_empty() => {
|
||||||
|
ok += 1;
|
||||||
|
send_sse(tx, json!({ "type": "image", "character": name, "view": view, "url": url }));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("{} 的 {} 视图生成失败: {}", name, view, e) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("已为角色「{}」生成 {} 张视图。", name, ok)
|
||||||
|
}
|
||||||
|
"generate_scene_image" => {
|
||||||
|
let scene_id = args.get("scene_id").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let desc = args.get("description").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let Some(prompt) = args.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
||||||
|
return "缺少 prompt,未生成场景图。".to_string();
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成场景 {}", scene_id) }));
|
||||||
|
match call_image_gen(api_base, api_key, image_model, prompt).await {
|
||||||
|
Ok(url) if !url.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "scene", "id": scene_id, "description": desc, "prompt": prompt, "url": url }));
|
||||||
|
format!("场景 {} 已生成。", scene_id)
|
||||||
|
}
|
||||||
|
Ok(_) => format!("场景 {} 上游未返回图像。", scene_id),
|
||||||
|
Err(e) => format!("场景 {} 生成失败: {}", scene_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"generate_tts" => {
|
||||||
|
let character = args.get("character").and_then(Value::as_str).unwrap_or("旁白");
|
||||||
|
let emotion = args.get("emotion").and_then(Value::as_str).unwrap_or("平静");
|
||||||
|
let Some(text) = args.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
|
||||||
|
return "缺少台词文本,未生成语音。".to_string();
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("为 {} 合成语音", character) }));
|
||||||
|
match call_tts(api_base, api_key, text, voice_for_emotion(emotion)).await {
|
||||||
|
Ok(audio_bytes) => {
|
||||||
|
let audio_path = format!(
|
||||||
|
"/tmp/comfyui_tts_{}_{}.mp3",
|
||||||
|
character.replace(' ', "_"),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
);
|
||||||
|
if tokio::fs::write(&audio_path, &audio_bytes).await.is_ok() {
|
||||||
|
send_sse(tx, json!({
|
||||||
|
"type": "tts",
|
||||||
|
"character": character,
|
||||||
|
"text": text,
|
||||||
|
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||||
|
}));
|
||||||
|
format!("已为「{}」生成语音。", character)
|
||||||
|
} else {
|
||||||
|
"语音生成成功但写入失败。".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => format!("语音生成失败: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"build_comfyui_workflow" => {
|
||||||
|
let Some(raw) = args.get("workflow_json").and_then(Value::as_str).filter(|w| !w.is_empty()) else {
|
||||||
|
return "缺少 workflow_json。".to_string();
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<Value>(raw) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
send_sse(tx, json!({ "type": "comfyui_workflow", "workflow": parsed }));
|
||||||
|
"ComfyUI 工作流已交付。".to_string()
|
||||||
|
}
|
||||||
|
Err(e) => format!("工作流 JSON 无效: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => format!("未知工具: {}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
keys.iter()
|
keys.iter()
|
||||||
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||||
@@ -910,6 +1077,15 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Agentic loop: each round may produce tool_calls; we execute them,
|
||||||
|
// feed results back, and re-query until the model returns a final text
|
||||||
|
// answer (no tool_calls) or the round budget is exhausted.
|
||||||
|
const MAX_AGENT_ROUNDS: usize = 6;
|
||||||
|
let mut agent_round = 0usize;
|
||||||
|
let mut accumulated_text = String::new();
|
||||||
|
loop {
|
||||||
|
agent_round += 1;
|
||||||
|
|
||||||
info!("comfyui-agent: sending chat request to upstream");
|
info!("comfyui-agent: sending chat request to upstream");
|
||||||
|
|
||||||
let response = match client
|
let response = match client
|
||||||
@@ -920,6 +1096,8 @@ async fn chat_handler(
|
|||||||
"messages": upstream_messages,
|
"messages": upstream_messages,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"stream": true,
|
"stream": true,
|
||||||
|
"tools": agent_tool_definitions(),
|
||||||
|
"tool_choice": "auto",
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -952,7 +1130,10 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut buffer: Vec<u8> = Vec::new();
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
let mut accumulated_text = String::new();
|
accumulated_text.clear();
|
||||||
|
// Accumulate streamed tool_calls: index -> (id, name, arguments-buffer).
|
||||||
|
let mut tool_calls_acc: std::collections::BTreeMap<i64, (String, String, String)> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
|
||||||
// Phase 1: Stream the LLM text response
|
// Phase 1: Stream the LLM text response
|
||||||
loop {
|
loop {
|
||||||
@@ -1048,9 +1229,75 @@ async fn chat_handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate streamed tool_calls fragments.
|
||||||
|
if let Some(tcs) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||||
|
for tc in tcs {
|
||||||
|
let idx = tc.get("index").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let entry = tool_calls_acc.entry(idx).or_default();
|
||||||
|
if let Some(id) = tc.get("id").and_then(Value::as_str) {
|
||||||
|
if !id.is_empty() {
|
||||||
|
entry.0 = id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(name) = tc.pointer("/function/name").and_then(Value::as_str) {
|
||||||
|
if !name.is_empty() {
|
||||||
|
entry.1 = name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(args) = tc.pointer("/function/arguments").and_then(Value::as_str) {
|
||||||
|
entry.2.push_str(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the model requested tools this round, execute them, append the
|
||||||
|
// assistant tool_calls + tool results to the conversation, and loop
|
||||||
|
// for another round so the model can react to the results.
|
||||||
|
if !tool_calls_acc.is_empty() && agent_round < MAX_AGENT_ROUNDS {
|
||||||
|
// Reconstruct the assistant message carrying the tool_calls.
|
||||||
|
let assistant_tool_calls: Vec<Value> = tool_calls_acc
|
||||||
|
.iter()
|
||||||
|
.map(|(_, (id, name, args))| {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": { "name": name, "arguments": args },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
upstream_messages.push(json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": if accumulated_text.is_empty() { Value::Null } else { Value::from(accumulated_text.clone()) },
|
||||||
|
"tool_calls": assistant_tool_calls,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (_, (id, name, args)) in tool_calls_acc.iter() {
|
||||||
|
let parsed_args: Value = serde_json::from_str(args).unwrap_or(json!({}));
|
||||||
|
send_sse(&tx, json!({ "type": "tool_call", "tool": name, "arguments": parsed_args }));
|
||||||
|
let result = execute_agent_tool(
|
||||||
|
&tx, &api_base, &api_key, &image_model, name, &parsed_args,
|
||||||
|
).await;
|
||||||
|
send_sse(&tx, json!({ "type": "tool_result", "tool": name, "result": result }));
|
||||||
|
upstream_messages.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": id,
|
||||||
|
"content": result,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to the next agent round.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tool calls → final answer. Leave the agentic loop and fall through
|
||||||
|
// to the legacy structured-section parsing as a fallback for models
|
||||||
|
// that emit 【...】 markers instead of tool calls.
|
||||||
|
break;
|
||||||
|
} // end agentic loop
|
||||||
|
|
||||||
// Phase 2: Parse structured sections from accumulated text
|
// Phase 2: Parse structured sections from accumulated text
|
||||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||||
let sections = parse_sections(&accumulated_text);
|
let sections = parse_sections(&accumulated_text);
|
||||||
@@ -1305,6 +1552,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+1738
-99
File diff suppressed because it is too large
Load Diff
+49
-3
@@ -439,6 +439,12 @@ struct EventListItem {
|
|||||||
event_type: String,
|
event_type: String,
|
||||||
title: String,
|
title: String,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
account: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
client_ip: Option<String>,
|
||||||
level: String,
|
level: String,
|
||||||
#[serde(rename = "read")]
|
#[serde(rename = "read")]
|
||||||
is_read: bool,
|
is_read: bool,
|
||||||
@@ -449,11 +455,15 @@ struct EventListItem {
|
|||||||
|
|
||||||
impl From<Event> for EventListItem {
|
impl From<Event> for EventListItem {
|
||||||
fn from(event: Event) -> Self {
|
fn from(event: Event) -> Self {
|
||||||
|
let account = event_account(&event.event_type, &event.title, event.message.as_deref());
|
||||||
|
let client_ip = event_client_ip(event.message.as_deref());
|
||||||
Self {
|
Self {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
event_type: event.event_type,
|
event_type: event.event_type,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
message: event.message,
|
message: event.message,
|
||||||
|
account,
|
||||||
|
client_ip,
|
||||||
level: event.level.unwrap_or_else(|| "info".to_string()),
|
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||||
is_read: event.is_read.unwrap_or(false),
|
is_read: event.is_read.unwrap_or(false),
|
||||||
related_id: event.related_id,
|
related_id: event.related_id,
|
||||||
@@ -470,6 +480,8 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
|||||||
event_type: entry.event_type,
|
event_type: entry.event_type,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
|
account: None,
|
||||||
|
client_ip: None,
|
||||||
level: entry.level,
|
level: entry.level,
|
||||||
is_read: entry.is_read,
|
is_read: entry.is_read,
|
||||||
related_id: entry.related_id,
|
related_id: entry.related_id,
|
||||||
@@ -479,6 +491,38 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn event_account(event_type: &str, title: &str, message: Option<&str>) -> Option<String> {
|
||||||
|
if event_type != "user_login" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
message
|
||||||
|
.and_then(|value| keyed_message_value(value, "账号"))
|
||||||
|
.or_else(|| message.and_then(|value| keyed_message_value(value, "邮箱")))
|
||||||
|
.or_else(|| message.and_then(|value| keyed_message_value(value, "标识")))
|
||||||
|
.or_else(|| {
|
||||||
|
title
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, value)| value.trim().to_string())
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_client_ip(message: Option<&str>) -> Option<String> {
|
||||||
|
message
|
||||||
|
.and_then(|value| keyed_message_value(value, "IP"))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyed_message_value(message: &str, key: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{key}=");
|
||||||
|
message
|
||||||
|
.split('·')
|
||||||
|
.map(str::trim)
|
||||||
|
.find_map(|part| part.strip_prefix(&prefix).map(str::trim))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -487,11 +531,11 @@ mod tests {
|
|||||||
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
};
|
};
|
||||||
|
use axum::extract::{Query, State};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
};
|
};
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use tower::ServiceExt as _;
|
use tower::ServiceExt as _;
|
||||||
|
|
||||||
@@ -536,6 +580,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -631,8 +677,8 @@ mod tests {
|
|||||||
.expect("admin token request");
|
.expect("admin token request");
|
||||||
assert_eq!(ok_response.status(), StatusCode::OK);
|
assert_eq!(ok_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
|
let user_jwt =
|
||||||
.expect("user jwt");
|
generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1).expect("user jwt");
|
||||||
let user_response = app
|
let user_response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
+18
-7
@@ -16,12 +16,16 @@ use tower_http::services::ServeDir;
|
|||||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||||
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
|
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
|
||||||
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
|
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
|
||||||
const _: &[u8] =
|
const _: &[u8] = include_bytes!(concat!(
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/dist/website/index.html"
|
||||||
|
));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod agent;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod checkin;
|
mod checkin;
|
||||||
|
mod comfy_cloud_adapter;
|
||||||
mod comfyui;
|
mod comfyui;
|
||||||
mod comfyui_agent;
|
mod comfyui_agent;
|
||||||
mod digital_human;
|
mod digital_human;
|
||||||
@@ -30,6 +34,7 @@ mod health;
|
|||||||
mod money_printer;
|
mod money_printer;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
|
mod payment;
|
||||||
mod picture_agent;
|
mod picture_agent;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod search;
|
mod search;
|
||||||
@@ -50,8 +55,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(admin::routes(state.clone()))
|
.merge(admin::routes(state.clone()))
|
||||||
.merge(proxy::routes())
|
.merge(proxy::routes())
|
||||||
.merge(picture_agent::routes())
|
.merge(picture_agent::routes())
|
||||||
|
.merge(comfy_cloud_adapter::routes())
|
||||||
.merge(money_printer::routes())
|
.merge(money_printer::routes())
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
|
.merge(agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
.merge(crate::workflow::routes::routes())
|
.merge(crate::workflow::routes::routes())
|
||||||
@@ -65,6 +72,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(oauth::routes())
|
.merge(oauth::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
|
.merge(payment::routes())
|
||||||
.merge(search::routes())
|
.merge(search::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
.merge(workflow_ui::routes())
|
.merge(workflow_ui::routes())
|
||||||
@@ -99,12 +107,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Infallible>(filesystem_static_response(
|
Ok::<_, Infallible>(
|
||||||
path,
|
filesystem_static_response(path, &web_dir, &website_dir).await,
|
||||||
&web_dir,
|
|
||||||
&website_dir,
|
|
||||||
)
|
)
|
||||||
.await)
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
router.fallback_service(static_service)
|
router.fallback_service(static_service)
|
||||||
@@ -285,6 +290,12 @@ mod tests {
|
|||||||
assert_eq!(embedded_asset_path("/new"), "new.html");
|
assert_eq!(embedded_asset_path("/new"), "new.html");
|
||||||
assert_eq!(embedded_asset_path("/new/"), "new.html");
|
assert_eq!(embedded_asset_path("/new/"), "new.html");
|
||||||
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
|
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
|
||||||
|
|
||||||
|
for route in ["new1", "new2", "new3", "new4", "new5"] {
|
||||||
|
assert!(!should_serve_website(&format!("/{route}")));
|
||||||
|
assert!(!should_serve_website(&format!("/{route}/")));
|
||||||
|
assert!(!should_serve_website(&format!("/{route}/index.html")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+349
-110
@@ -108,7 +108,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
|||||||
headers
|
headers
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| value.strip_prefix("bearer "))
|
||||||
|
})
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|token| !token.is_empty())
|
.filter(|token| !token.is_empty())
|
||||||
}
|
}
|
||||||
@@ -181,25 +185,28 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
|||||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
let auth = resolve_auth(&headers, &state).await?;
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
let cloud_backend = state.config.comfyui.uses_cloud_backend();
|
let cloud_backend = state.config.comfyui.uses_cloud_backend();
|
||||||
|
let local_only_enabled = state.config.comfyui.uses_docker_backend();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"mode": "boosapi-native",
|
"mode": if local_only_enabled { "local-comfy" } else { "disabled" },
|
||||||
|
"disabled": !local_only_enabled,
|
||||||
|
"disabledReason": if local_only_enabled { Value::Null } else { json!("短视频工厂暂时只支持本地模型部署;请启用本地 ComfyUI/Docker 后再创建任务。") },
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"topic_to_script",
|
"topic_to_script",
|
||||||
"boosapi_chat_completion",
|
"boosapi_chat_completion",
|
||||||
"comfy_prompt",
|
"local_comfy_prompt",
|
||||||
"boosapi_text_to_video",
|
"local_text_to_video",
|
||||||
"workspace_archive",
|
"workspace_archive",
|
||||||
"background_jobs"
|
"background_jobs"
|
||||||
],
|
],
|
||||||
"comfy": {
|
"comfy": {
|
||||||
"enabled": state.config.comfyui.uses_native_backend(),
|
"enabled": local_only_enabled,
|
||||||
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
||||||
"baseUrl": state.config.comfyui.target_base_url()
|
"baseUrl": state.config.comfyui.target_base_url()
|
||||||
},
|
},
|
||||||
"builtinCloudTemplate": {
|
"builtinCloudTemplate": {
|
||||||
"enabled": cloud_backend,
|
"enabled": cloud_backend && local_only_enabled,
|
||||||
"label": "内置 Cloud Seedance 文生视频",
|
"label": "内置 Cloud Seedance 文生视频",
|
||||||
"nodeType": "ByteDanceTextToVideoNode",
|
"nodeType": "ByteDanceTextToVideoNode",
|
||||||
"model": "seedance-1-0-lite-t2v-250428",
|
"model": "seedance-1-0-lite-t2v-250428",
|
||||||
@@ -276,7 +283,12 @@ async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppRes
|
|||||||
Ok(task_to_json(task))
|
Ok(task_to_json(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
|
async fn update_progress(
|
||||||
|
state: &AppState,
|
||||||
|
id: &str,
|
||||||
|
progress: i64,
|
||||||
|
message: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE background_tasks
|
"UPDATE background_tasks
|
||||||
SET progress = ?, message = ?, updated_at = datetime('now')
|
SET progress = ?, message = ?, updated_at = datetime('now')
|
||||||
@@ -308,7 +320,8 @@ fn http_client(timeout_secs: u64) -> Client {
|
|||||||
|
|
||||||
fn script_prompt(topic: &str, body: &Value) -> String {
|
fn script_prompt(topic: &str, body: &Value) -> String {
|
||||||
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
|
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
|
||||||
let style = string_from(body, &["style"]).unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
|
let style = string_from(body, &["style"])
|
||||||
|
.unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
|
||||||
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
|
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
|
||||||
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
|
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
|
||||||
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
|
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
|
||||||
@@ -334,9 +347,8 @@ async fn generate_script(
|
|||||||
auth: &MoneyPrinterAuth,
|
auth: &MoneyPrinterAuth,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
|
let topic = string_from(body, &["topic", "prompt"])
|
||||||
AppError::BadRequest("topic is required".into())
|
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||||
})?;
|
|
||||||
let script_model = string_from(body, &["scriptModel", "script_model"])
|
let script_model = string_from(body, &["scriptModel", "script_model"])
|
||||||
.unwrap_or_else(|| "gpt-5.4".to_string());
|
.unwrap_or_else(|| "gpt-5.4".to_string());
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
@@ -452,7 +464,11 @@ fn native_path(state: &AppState, path: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn native_url(state: &AppState, path: &str) -> String {
|
fn native_url(state: &AppState, path: &str) -> String {
|
||||||
format!("{}{}", state.config.comfyui.target_base_url(), native_path(state, path))
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
state.config.comfyui.target_base_url(),
|
||||||
|
native_path(state, path)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
|
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
|
||||||
@@ -559,7 +575,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
|||||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||||
mapping_value(mapping, keys).and_then(|value| {
|
mapping_value(mapping, keys).and_then(|value| {
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
|
scalar_string(
|
||||||
|
value
|
||||||
|
.get("nodeId")
|
||||||
|
.or_else(|| value.get("node_id"))
|
||||||
|
.or(Some(value)),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
scalar_string(Some(value))
|
scalar_string(Some(value))
|
||||||
}
|
}
|
||||||
@@ -621,21 +642,38 @@ fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
|
|||||||
|
|
||||||
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
||||||
match (class_type, field) {
|
match (class_type, field) {
|
||||||
("KSampler", "cfg") => value.as_f64().filter(|v| (0.0..=50.0).contains(v)).map_or(json!(8.0), |v| json!(v)),
|
("KSampler", "cfg") => value
|
||||||
("KSampler", "denoise") => value.as_f64().map(|v| if v > 1.0 { v / 100.0 } else { v }).filter(|v| (0.0..=1.0).contains(v)).map_or(json!(1.0), |v| json!(v)),
|
.as_f64()
|
||||||
|
.filter(|v| (0.0..=50.0).contains(v))
|
||||||
|
.map_or(json!(8.0), |v| json!(v)),
|
||||||
|
("KSampler", "denoise") => value
|
||||||
|
.as_f64()
|
||||||
|
.map(|v| if v > 1.0 { v / 100.0 } else { v })
|
||||||
|
.filter(|v| (0.0..=1.0).contains(v))
|
||||||
|
.map_or(json!(1.0), |v| json!(v)),
|
||||||
("KSampler", "sampler_name") => {
|
("KSampler", "sampler_name") => {
|
||||||
if value.is_string() { value } else { json!("euler") }
|
if value.is_string() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
json!("euler")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
("KSampler", "scheduler") => {
|
("KSampler", "scheduler") => {
|
||||||
if value.is_string() { value } else { json!("normal") }
|
if value.is_string() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
json!("normal")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
|
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
|
||||||
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
|
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
|
||||||
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
|
.as_i64()
|
||||||
}
|
.filter(|v| (64..=4096).contains(v))
|
||||||
("EmptyLatentImage", "batch_size") => {
|
.map_or(json!(1024), |v| json!(v)),
|
||||||
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
|
("EmptyLatentImage", "batch_size") => value
|
||||||
}
|
.as_i64()
|
||||||
|
.filter(|v| (1..=16).contains(v))
|
||||||
|
.map_or(json!(1), |v| json!(v)),
|
||||||
_ => value,
|
_ => value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,18 +694,37 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
let mut inputs = serde_json::Map::new();
|
let mut inputs = serde_json::Map::new();
|
||||||
for (field, value) in graph_node_params(node) {
|
for (field, value) in graph_node_params(node) {
|
||||||
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
|
inputs.insert(
|
||||||
|
field.clone(),
|
||||||
|
normalize_comfy_value(&class_type, &field, value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
prompt.insert(id, json!({
|
prompt.insert(
|
||||||
"class_type": class_type,
|
id,
|
||||||
"inputs": inputs,
|
json!({
|
||||||
}));
|
"class_type": class_type,
|
||||||
|
"inputs": inputs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
|
for edge in graph
|
||||||
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
|
.get("edges")
|
||||||
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
|
.and_then(Value::as_array)
|
||||||
let Some((from_id, from_port)) = split_endpoint(from) else { continue; };
|
.into_iter()
|
||||||
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(from) = edge.get("from").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(to) = edge.get("to").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((from_id, from_port)) = split_endpoint(from) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((to_id, to_field)) = split_endpoint(to) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if let Some(inputs) = prompt
|
if let Some(inputs) = prompt
|
||||||
.get_mut(to_id)
|
.get_mut(to_id)
|
||||||
.and_then(Value::as_object_mut)
|
.and_then(Value::as_object_mut)
|
||||||
@@ -684,7 +741,10 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
Some(Value::Object(prompt))
|
Some(Value::Object(prompt))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
|
fn first_node_by_class(
|
||||||
|
prompt: &serde_json::Map<String, Value>,
|
||||||
|
class_type: &str,
|
||||||
|
) -> Option<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||||
@@ -716,9 +776,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
|||||||
.get("class_type")
|
.get("class_type")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.is_some_and(|value| value == class_type)
|
.is_some_and(|value| value == class_type)
|
||||||
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|
|| object
|
||||||
|
.values()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type))
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type)),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,11 +804,36 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
.or_else(|| first_node_by_class(object, "KSampler"))
|
.or_else(|| first_node_by_class(object, "KSampler"))
|
||||||
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
|
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
|
let prompt_field = first_existing_input_field(
|
||||||
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
|
object,
|
||||||
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
|
&prompt_node,
|
||||||
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
|
&["prompt", "positive", "text"],
|
||||||
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
|
"text",
|
||||||
|
);
|
||||||
|
let ratio_field = first_existing_input_field(
|
||||||
|
object,
|
||||||
|
&video_node,
|
||||||
|
&["ratio", "model.ratio", "aspect_ratio"],
|
||||||
|
"ratio",
|
||||||
|
);
|
||||||
|
let resolution_field = first_existing_input_field(
|
||||||
|
object,
|
||||||
|
&video_node,
|
||||||
|
&["model.resolution", "resolution", "size"],
|
||||||
|
"resolution",
|
||||||
|
);
|
||||||
|
let duration_field = first_existing_input_field(
|
||||||
|
object,
|
||||||
|
&video_node,
|
||||||
|
&["model.duration", "duration", "length"],
|
||||||
|
"duration",
|
||||||
|
);
|
||||||
|
let model_field = first_existing_input_field(
|
||||||
|
object,
|
||||||
|
&video_node,
|
||||||
|
&["model", "model_name", "ckpt_name"],
|
||||||
|
"model",
|
||||||
|
);
|
||||||
json!({
|
json!({
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"nodeId": prompt_node,
|
"nodeId": prompt_node,
|
||||||
@@ -776,7 +865,9 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
|
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode"))
|
.filter(|(_, node)| {
|
||||||
|
node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode")
|
||||||
|
})
|
||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -826,7 +917,8 @@ fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
||||||
graph.get("prompt")
|
graph
|
||||||
|
.get("prompt")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| graph.get("workflow").cloned())
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
@@ -858,23 +950,24 @@ async fn workflow_prompt_from_body(
|
|||||||
auth: &MoneyPrinterAuth,
|
auth: &MoneyPrinterAuth,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
if let Some(value) = body.get("comfyPrompt")
|
if let Some(value) = body
|
||||||
|
.get("comfyPrompt")
|
||||||
.or_else(|| body.get("comfy_prompt"))
|
.or_else(|| body.get("comfy_prompt"))
|
||||||
.or_else(|| body.get("promptWorkflow"))
|
.or_else(|| body.get("promptWorkflow"))
|
||||||
.or_else(|| body.get("prompt_workflow"))
|
.or_else(|| body.get("prompt_workflow"))
|
||||||
.or_else(|| body.get("workflowPrompt"))
|
.or_else(|| body.get("workflowPrompt"))
|
||||||
.or_else(|| body.get("workflow_prompt"))
|
.or_else(|| body.get("workflow_prompt"))
|
||||||
.cloned() {
|
.cloned()
|
||||||
|
{
|
||||||
return Ok(value);
|
return Ok(value);
|
||||||
}
|
}
|
||||||
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
|
if let Some(template_id) = string_from(
|
||||||
let workflow = storage::get_workflow(
|
body,
|
||||||
&state.db,
|
&["templateId", "template_id", "workflowId", "workflow_id"],
|
||||||
&template_id,
|
) {
|
||||||
auth.user_id,
|
let workflow =
|
||||||
auth.role == "admin",
|
storage::get_workflow(&state.db, &template_id, auth.user_id, auth.role == "admin")
|
||||||
)
|
.await?;
|
||||||
.await?;
|
|
||||||
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
||||||
}
|
}
|
||||||
if state.config.comfyui.uses_cloud_backend() {
|
if state.config.comfyui.uses_cloud_backend() {
|
||||||
@@ -890,10 +983,14 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
|
|||||||
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
|
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
|
||||||
}
|
}
|
||||||
let Some(node) = prompt.get_mut(node_id) else {
|
let Some(node) = prompt.get_mut(node_id) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy prompt missing node {node_id}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy node {node_id} has no inputs"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
inputs.insert(field.to_string(), value);
|
inputs.insert(field.to_string(), value);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -927,12 +1024,20 @@ fn apply_optional_node_input(
|
|||||||
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
||||||
value
|
value
|
||||||
.as_i64()
|
.as_i64()
|
||||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||||
|
})
|
||||||
.map(|duration| duration.clamp(2, 120))
|
.map(|duration| duration.clamp(2, 120))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
||||||
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
match text.as_str() {
|
match text.as_str() {
|
||||||
"720p" | "720" => json!("720P"),
|
"720p" | "720" => json!("720P"),
|
||||||
"1080p" | "1080" => json!("1080P"),
|
"1080p" | "1080" => json!("1080P"),
|
||||||
@@ -940,7 +1045,12 @@ fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
|
fn inject_comfy_video_inputs(
|
||||||
|
prompt: &mut Value,
|
||||||
|
mapping: &Value,
|
||||||
|
body: &Value,
|
||||||
|
plan: &Value,
|
||||||
|
) -> AppResult<String> {
|
||||||
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
|
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
|
||||||
plan.get("video_prompt")
|
plan.get("video_prompt")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -956,20 +1066,35 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&prompt_node,
|
&prompt_node,
|
||||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["prompt", "positive", "text", "promptNode"],
|
||||||
|
"prompt",
|
||||||
|
),
|
||||||
json!(video_prompt),
|
json!(video_prompt),
|
||||||
)?;
|
)?;
|
||||||
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
|
if let Some(negative_node) =
|
||||||
|
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
|
||||||
|
{
|
||||||
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
|
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
|
||||||
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
|
.unwrap_or_else(|| {
|
||||||
|
"low quality, blurry, distorted, watermark, text artifacts".to_string()
|
||||||
|
});
|
||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&negative_node,
|
&negative_node,
|
||||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["negativePrompt", "negative", "negativeNode"],
|
||||||
|
"text",
|
||||||
|
),
|
||||||
json!(negative),
|
json!(negative),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(model) = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
|
if let Some(model) = string_from(
|
||||||
|
body,
|
||||||
|
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
|
||||||
|
) {
|
||||||
apply_optional_node_input(
|
apply_optional_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
mapping,
|
mapping,
|
||||||
@@ -978,10 +1103,16 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
json!(normalize_video_model(&model)),
|
json!(normalize_video_model(&model)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
|
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| body.get(*key).cloned())
|
||||||
|
{
|
||||||
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
|
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
|
if let Some(value) = ["resolution", "size"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| body.get(*key).cloned())
|
||||||
|
{
|
||||||
apply_optional_node_input(
|
apply_optional_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
mapping,
|
mapping,
|
||||||
@@ -990,8 +1121,18 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
normalize_resolution_for_comfy(&value),
|
normalize_resolution_for_comfy(&value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
|
if let Some(value) = ["duration"]
|
||||||
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
|
.iter()
|
||||||
|
.find_map(|key| body.get(*key))
|
||||||
|
.and_then(value_to_duration_seconds)
|
||||||
|
{
|
||||||
|
apply_optional_node_input(
|
||||||
|
prompt,
|
||||||
|
mapping,
|
||||||
|
&["duration", "durationNode"],
|
||||||
|
"duration",
|
||||||
|
json!(value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
repair_money_printer_comfy_prompt(prompt);
|
repair_money_printer_comfy_prompt(prompt);
|
||||||
Ok(video_prompt)
|
Ok(video_prompt)
|
||||||
@@ -1009,8 +1150,14 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for (_node_id, node) in nodes.iter_mut() {
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
let class_type = node
|
||||||
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !matches!(
|
||||||
|
class_type,
|
||||||
|
"ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode"
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
@@ -1019,16 +1166,20 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
||||||
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
||||||
}
|
}
|
||||||
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
if let Some(duration) = inputs
|
||||||
|
.get("model.duration")
|
||||||
|
.and_then(value_to_duration_seconds)
|
||||||
|
{
|
||||||
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
|
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
|
||||||
}
|
}
|
||||||
if inputs
|
if inputs.get("resolution").is_some_and(|value| {
|
||||||
.get("resolution")
|
let normalized = value
|
||||||
.is_some_and(|value| {
|
.as_str()
|
||||||
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
.unwrap_or_default()
|
||||||
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
.trim()
|
||||||
})
|
.to_ascii_lowercase();
|
||||||
{
|
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
||||||
|
}) {
|
||||||
inputs.insert("resolution".to_string(), json!("480p"));
|
inputs.insert("resolution".to_string(), json!("480p"));
|
||||||
} else if let Some(normalized) = inputs
|
} else if let Some(normalized) = inputs
|
||||||
.get("resolution")
|
.get("resolution")
|
||||||
@@ -1037,10 +1188,12 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
{
|
{
|
||||||
inputs.insert("resolution".to_string(), json!(normalized));
|
inputs.insert("resolution".to_string(), json!(normalized));
|
||||||
}
|
}
|
||||||
if inputs
|
if inputs.get("aspect_ratio").is_some_and(|value| {
|
||||||
.get("aspect_ratio")
|
!matches!(
|
||||||
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
|
value.as_str(),
|
||||||
{
|
Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")
|
||||||
|
)
|
||||||
|
}) {
|
||||||
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
|
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1214,10 @@ fn repair_resize_image_mask_nodes(prompt: &mut Value) {
|
|||||||
.get("resize_type.crop")
|
.get("resize_type.crop")
|
||||||
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
||||||
{
|
{
|
||||||
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
inputs.insert(
|
||||||
|
"resize_type.crop".to_string(),
|
||||||
|
Value::String("center".to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1244,9 @@ fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
|
|||||||
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
|
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.min_p".to_string(), json!(0.05));
|
||||||
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.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
|
||||||
|
.entry("sampling_mode.seed".to_string())
|
||||||
|
.or_insert_with(|| json!(0));
|
||||||
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1257,10 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for (_node_id, node) in nodes.iter_mut() {
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
let class_type = node
|
||||||
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
|
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1278,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
|||||||
inputs.insert("model".to_string(), model);
|
inputs.insert("model".to_string(), model);
|
||||||
}
|
}
|
||||||
if let Some(resolution) = items.get(1).cloned() {
|
if let Some(resolution) = items.get(1).cloned() {
|
||||||
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
|
inputs.insert(
|
||||||
|
"model.resolution".to_string(),
|
||||||
|
normalize_resolution_for_comfy(&resolution),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
||||||
inputs.insert("model.duration".to_string(), json!(duration));
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
@@ -1129,7 +1293,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
|||||||
{
|
{
|
||||||
inputs.insert("model.resolution".to_string(), json!("1080P"));
|
inputs.insert("model.resolution".to_string(), json!("1080P"));
|
||||||
}
|
}
|
||||||
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
if let Some(duration) = inputs
|
||||||
|
.get("model.duration")
|
||||||
|
.and_then(value_to_duration_seconds)
|
||||||
|
{
|
||||||
inputs.insert("model.duration".to_string(), json!(duration));
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1331,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
status.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Some(
|
return Some(
|
||||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
.or_else(|| scalar_string(value.get("error")))
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
@@ -1182,7 +1352,10 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
||||||
let identity = output_identity(&item);
|
let identity = output_identity(&item);
|
||||||
if out.iter().any(|existing| output_identity(existing) == identity) {
|
if out
|
||||||
|
.iter()
|
||||||
|
.any(|existing| output_identity(existing) == identity)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
out.push(item);
|
out.push(item);
|
||||||
@@ -1229,7 +1402,10 @@ fn output_identity(item: &Value) -> String {
|
|||||||
|
|
||||||
fn looks_like_media_reference(value: &str) -> bool {
|
fn looks_like_media_reference(value: &str) -> bool {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
|
if !(trimmed.starts_with("http://")
|
||||||
|
|| trimmed.starts_with("https://")
|
||||||
|
|| trimmed.starts_with('/'))
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let lower = trimmed
|
let lower = trimmed
|
||||||
@@ -1328,7 +1504,8 @@ fn output_kind(item: &Value) -> &'static str {
|
|||||||
|| filename.ends_with(".gif")
|
|| filename.ends_with(".gif")
|
||||||
{
|
{
|
||||||
"video"
|
"video"
|
||||||
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
|
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a")
|
||||||
|
{
|
||||||
"audio"
|
"audio"
|
||||||
} else {
|
} else {
|
||||||
"image"
|
"image"
|
||||||
@@ -1390,7 +1567,11 @@ async fn archive_comfy_output(
|
|||||||
}
|
}
|
||||||
let query = output_query(item)?;
|
let query = output_query(item)?;
|
||||||
let kind = output_kind(item).to_string();
|
let kind = output_kind(item).to_string();
|
||||||
let path = if kind == "video" { "/viewvideo" } else { "/view" };
|
let path = if kind == "video" {
|
||||||
|
"/viewvideo"
|
||||||
|
} else {
|
||||||
|
"/view"
|
||||||
|
};
|
||||||
let response = native_request(state, Method::GET, &format!("{path}{query}"))
|
let response = native_request(state, Method::GET, &format!("{path}{query}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1455,9 +1636,12 @@ async fn submit_comfy_video(
|
|||||||
}
|
}
|
||||||
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
|
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
|
||||||
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
|
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
|
||||||
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
|
let model = string_from(
|
||||||
.map(|value| normalize_video_model(&value))
|
body,
|
||||||
.unwrap_or_else(|| "comfy-api".to_string());
|
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
|
||||||
|
)
|
||||||
|
.map(|value| normalize_video_model(&value))
|
||||||
|
.unwrap_or_else(|| "comfy-api".to_string());
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"backend": "comfy",
|
"backend": "comfy",
|
||||||
"promptId": prompt_id,
|
"promptId": prompt_id,
|
||||||
@@ -1488,7 +1672,8 @@ async fn submit_video(
|
|||||||
return Err(AppError::BadRequest("video prompt is empty".into()));
|
return Err(AppError::BadRequest("video prompt is empty".into()));
|
||||||
}
|
}
|
||||||
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
|
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
|
||||||
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"]).unwrap_or_else(|| "9:16".to_string());
|
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"])
|
||||||
|
.unwrap_or_else(|| "9:16".to_string());
|
||||||
let duration = body
|
let duration = body
|
||||||
.get("duration")
|
.get("duration")
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
@@ -1559,7 +1744,10 @@ async fn poll_video(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(state_text.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
state_text.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Err(AppError::UpstreamError(value.to_string()));
|
return Err(AppError::UpstreamError(value.to_string()));
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
|
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
|
||||||
@@ -1617,8 +1805,15 @@ async fn create_job(
|
|||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let auth = resolve_auth(&headers, &state).await?;
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
if !state.config.comfyui.uses_docker_backend() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"短视频工厂暂时只支持本地模型部署;当前 Comfy/RunningHub/Cloud API 路径已禁用。".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(key_id) = auth.key_id {
|
if let Some(key_id) = auth.key_id {
|
||||||
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
|
UsageTracker::new(state.db.clone())
|
||||||
|
.check_rate_limit(key_id)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
let topic = string_from(&body, &["topic", "prompt"])
|
let topic = string_from(&body, &["topic", "prompt"])
|
||||||
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||||
@@ -1656,7 +1851,15 @@ async fn create_job(
|
|||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
let failure_model = string_from(
|
let failure_model = string_from(
|
||||||
&failure_body,
|
&failure_body,
|
||||||
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "videoModel", "video_model", "model"],
|
&[
|
||||||
|
"comfyApiModel",
|
||||||
|
"comfy_api_model",
|
||||||
|
"apiModel",
|
||||||
|
"api_model",
|
||||||
|
"videoModel",
|
||||||
|
"video_model",
|
||||||
|
"model",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map(|value| normalize_video_model(&value))
|
.map(|value| normalize_video_model(&value))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
@@ -2021,7 +2224,10 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
|
config::{
|
||||||
|
AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig,
|
||||||
|
ServerConfig,
|
||||||
|
},
|
||||||
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
};
|
};
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
@@ -2071,6 +2277,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui,
|
comfyui,
|
||||||
|
payment: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -2081,7 +2289,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn status_exposes_comfy_cloud_api_model_candidates() {
|
async fn status_disables_cloud_api_video_factory_but_keeps_diagnostics() {
|
||||||
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
|
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
@@ -2093,7 +2301,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("status response");
|
.expect("status response");
|
||||||
|
|
||||||
assert_eq!(value["builtinCloudTemplate"]["enabled"], true);
|
assert_eq!(value["mode"], "disabled");
|
||||||
|
assert_eq!(value["disabled"], true);
|
||||||
|
assert_eq!(value["builtinCloudTemplate"]["enabled"], false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["builtinCloudTemplate"]["model"],
|
value["builtinCloudTemplate"]["model"],
|
||||||
"seedance-1-0-lite-t2v-250428"
|
"seedance-1-0-lite-t2v-250428"
|
||||||
@@ -2132,8 +2342,13 @@ mod tests {
|
|||||||
|
|
||||||
let outputs = comfy_output_objects(&history);
|
let outputs = comfy_output_objects(&history);
|
||||||
assert_eq!(outputs.len(), 3);
|
assert_eq!(outputs.len(), 3);
|
||||||
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
|
assert!(outputs
|
||||||
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/poster.png")));
|
.iter()
|
||||||
|
.any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
|
||||||
|
assert!(outputs
|
||||||
|
.iter()
|
||||||
|
.any(|item| output_remote_url(item).as_deref()
|
||||||
|
== Some("https://cdn.example.com/poster.png")));
|
||||||
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
|
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2152,7 +2367,10 @@ mod tests {
|
|||||||
|
|
||||||
let outputs = comfy_output_objects(&history);
|
let outputs = comfy_output_objects(&history);
|
||||||
assert_eq!(outputs.len(), 1);
|
assert_eq!(outputs.len(), 1);
|
||||||
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
|
assert_eq!(
|
||||||
|
output_remote_url(&outputs[0]).as_deref(),
|
||||||
|
Some("https://cdn.example.com/final.mp4")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2161,7 +2379,9 @@ mod tests {
|
|||||||
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
||||||
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
||||||
assert!(!is_pending_history_status(StatusCode::OK));
|
assert!(!is_pending_history_status(StatusCode::OK));
|
||||||
assert!(!is_pending_history_status(StatusCode::INTERNAL_SERVER_ERROR));
|
assert!(!is_pending_history_status(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2199,10 +2419,29 @@ mod tests {
|
|||||||
.expect("injecting Comfy video inputs should succeed");
|
.expect("injecting Comfy video inputs should succeed");
|
||||||
|
|
||||||
assert_eq!(prompt_text, "cinematic product demo");
|
assert_eq!(prompt_text, "cinematic product demo");
|
||||||
assert_eq!(prompt.pointer("/1/inputs/model").and_then(Value::as_str), Some("seedance-1-0-lite-t2v-250428"));
|
assert_eq!(
|
||||||
assert_eq!(prompt.pointer("/1/inputs/prompt").and_then(Value::as_str), Some("cinematic product demo"));
|
prompt.pointer("/1/inputs/model").and_then(Value::as_str),
|
||||||
assert_eq!(prompt.pointer("/1/inputs/aspect_ratio").and_then(Value::as_str), Some("16:9"));
|
Some("seedance-1-0-lite-t2v-250428")
|
||||||
assert_eq!(prompt.pointer("/1/inputs/resolution").and_then(Value::as_str), Some("720p"));
|
);
|
||||||
assert_eq!(prompt.pointer("/1/inputs/duration").and_then(Value::as_i64), Some(8));
|
assert_eq!(
|
||||||
|
prompt.pointer("/1/inputs/prompt").and_then(Value::as_str),
|
||||||
|
Some("cinematic product demo")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt
|
||||||
|
.pointer("/1/inputs/aspect_ratio")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("16:9")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt
|
||||||
|
.pointer("/1/inputs/resolution")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("720p")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt.pointer("/1/inputs/duration").and_then(Value::as_i64),
|
||||||
|
Some(8)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user