Compare commits
81 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 | |||
| bfd804f719 | |||
| e7f3da5961 | |||
| 5cfd533d89 | |||
| 0b7abed79a | |||
| a89edf6f75 | |||
| 5d42671172 | |||
| ca9e45c59c | |||
| be7538a1da | |||
| cfe2098b73 | |||
| bd464016a8 | |||
| 7e8430934e | |||
| ab2fd271e8 | |||
| 4dd2be4cc3 | |||
| fd94303433 | |||
| 91dcbc5c53 | |||
| 278b0c66b8 |
@@ -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,694 @@
|
|||||||
|
# 计费闭环 实现计划
|
||||||
|
|
||||||
|
> **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 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
|
||||||
|
|
||||||
|
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构(第 1 期)
|
||||||
|
|
||||||
|
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
|
||||||
|
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
|
||||||
|
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
|
||||||
|
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
|
||||||
|
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
|
||||||
|
|
||||||
|
### Task 1:ledger 表结构 + 写入入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/billing/ledger.rs`
|
||||||
|
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn mem_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ensure_ledger_schema(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
let mut tx = pool.begin().await.unwrap();
|
||||||
|
record_ledger_entry(
|
||||||
|
&mut tx,
|
||||||
|
LedgerEntry {
|
||||||
|
user_id: 7,
|
||||||
|
track: "usd",
|
||||||
|
entry_type: "recharge",
|
||||||
|
amount: 10.0,
|
||||||
|
balance_after: 10.0,
|
||||||
|
ref_type: Some("stripe"),
|
||||||
|
ref_id: Some("cs_1"),
|
||||||
|
model: None,
|
||||||
|
operator: Some("system"),
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let row: (i64, String, String, f64, f64) = sqlx::query_as(
|
||||||
|
"SELECT user_id, track, entry_type, amount, balance_after
|
||||||
|
FROM credit_ledger WHERE user_id = 7",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||||
|
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use sqlx::{SqlitePool, Transaction};
|
||||||
|
|
||||||
|
/// One balance-changing event. Written to `credit_ledger` in the same
|
||||||
|
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
||||||
|
pub struct LedgerEntry<'a> {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub track: &'a str, // "usd" | "points"
|
||||||
|
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
|
||||||
|
pub amount: f64, // +credit / -debit
|
||||||
|
pub balance_after: f64,
|
||||||
|
pub ref_type: Option<&'a str>,
|
||||||
|
pub ref_id: Option<&'a str>,
|
||||||
|
pub model: Option<&'a str>,
|
||||||
|
pub operator: Option<&'a str>,
|
||||||
|
pub note: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
track TEXT NOT NULL,
|
||||||
|
entry_type TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
balance_after REAL NOT NULL,
|
||||||
|
ref_type TEXT,
|
||||||
|
ref_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
operator TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
|
||||||
|
ON credit_ledger (user_id, created_at DESC)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
|
||||||
|
ON credit_ledger (track, entry_type)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS user_credits (
|
||||||
|
user_id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
points_balance REAL NOT NULL DEFAULT 0,
|
||||||
|
points_total_recharged REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one ledger row inside an existing transaction. Caller is responsible
|
||||||
|
/// for also updating the cached balance in the same `tx`.
|
||||||
|
pub async fn record_ledger_entry(
|
||||||
|
tx: &mut Transaction<'_, sqlx::Sqlite>,
|
||||||
|
entry: LedgerEntry<'_>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO credit_ledger
|
||||||
|
(user_id, track, entry_type, amount, balance_after,
|
||||||
|
ref_type, ref_id, model, operator, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(entry.user_id)
|
||||||
|
.bind(entry.track)
|
||||||
|
.bind(entry.entry_type)
|
||||||
|
.bind(entry.amount)
|
||||||
|
.bind(entry.balance_after)
|
||||||
|
.bind(entry.ref_type)
|
||||||
|
.bind(entry.ref_id)
|
||||||
|
.bind(entry.model)
|
||||||
|
.bind(entry.operator)
|
||||||
|
.bind(entry.note)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||||
|
Expected: PASS(1 passed)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/billing/ledger.rs src/services/billing/mod.rs
|
||||||
|
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2:USD 消费接入 ledger(record_usage)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn record_usage_writes_consume_ledger_entry() {
|
||||||
|
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
|
||||||
|
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||||
|
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
|
||||||
|
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
|
||||||
|
|
||||||
|
let row: (String, String, f64, Option<String>) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount, model FROM credit_ledger
|
||||||
|
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||||
|
).fetch_one(&pool).await.unwrap();
|
||||||
|
assert_eq!(row.0, "usd");
|
||||||
|
assert_eq!(row.1, "consume");
|
||||||
|
assert_eq!(row.2, -0.42); // 消费为负
|
||||||
|
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
|
||||||
|
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn record_usage(
|
||||||
|
&self,
|
||||||
|
key_id: i64,
|
||||||
|
model: impl Into<String>,
|
||||||
|
prompt_tokens: i64,
|
||||||
|
completion_tokens: i64,
|
||||||
|
cost: f64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
|
||||||
|
let prompt_tokens = prompt_tokens.max(0);
|
||||||
|
let completion_tokens = completion_tokens.max(0);
|
||||||
|
let total_tokens = prompt_tokens + completion_tokens;
|
||||||
|
let cost = cost.max(0.0);
|
||||||
|
let model = model.into();
|
||||||
|
|
||||||
|
let mut tx = self.db.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO usage_logs
|
||||||
|
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(key_id).bind(&model)
|
||||||
|
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
|
||||||
|
.execute(&mut *tx).await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE downstream_api_keys
|
||||||
|
SET used_requests = COALESCE(used_requests,0)+1,
|
||||||
|
used_cost = COALESCE(used_cost,0)+?,
|
||||||
|
last_used_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
).bind(cost).bind(key_id).execute(&mut *tx).await?;
|
||||||
|
|
||||||
|
// Resolve owning user + new used_cost for the ledger snapshot.
|
||||||
|
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||||
|
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
|
||||||
|
).bind(key_id).fetch_optional(&mut *tx).await?;
|
||||||
|
if let Some((Some(user_id), max_cost, used_cost)) = owner {
|
||||||
|
let used = used_cost.unwrap_or(0.0);
|
||||||
|
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
|
||||||
|
crate::services::billing::ledger::record_ledger_entry(
|
||||||
|
&mut tx,
|
||||||
|
crate::services::billing::ledger::LedgerEntry {
|
||||||
|
user_id, track: "usd", entry_type: "consume",
|
||||||
|
amount: -cost, balance_after: remaining,
|
||||||
|
ref_type: Some("usage_log"), ref_id: None,
|
||||||
|
model: Some(&model), operator: Some("system"), note: None,
|
||||||
|
},
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/billing/mod.rs
|
||||||
|
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3:USD 充值接入 ledger(credit_user_balance)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credit_user_balance_writes_recharge_ledger() {
|
||||||
|
let pool = payment_test_pool().await;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||||
|
let mut tx = pool.begin().await.unwrap();
|
||||||
|
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
let row: (String, String, f64) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||||
|
).fetch_one(&pool).await.unwrap();
|
||||||
|
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行确认失败**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
|
||||||
|
Expected: FAIL。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// After the UPDATE that sets next_max_cost on the key:
|
||||||
|
crate::services::billing::ledger::record_ledger_entry(
|
||||||
|
tx,
|
||||||
|
crate::services::billing::ledger::LedgerEntry {
|
||||||
|
user_id, track: "usd", entry_type: "recharge",
|
||||||
|
amount: amount_usd.max(0.0),
|
||||||
|
balance_after: (next_max_cost - used_cost).max(0.0),
|
||||||
|
ref_type: Some("recharge_record"), ref_id: None,
|
||||||
|
model: None, operator: Some("system"), note: None,
|
||||||
|
},
|
||||||
|
).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行确认通过**
|
||||||
|
|
||||||
|
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
|
||||||
|
Expected: PASS(含已有的 recharge completion 测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/payment.rs
|
||||||
|
git commit -m "feat(billing): USD recharge writes credit_ledger"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/admin/users.rs
|
||||||
|
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5:全量回归
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
|
||||||
|
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
|
||||||
|
- [ ] **Step 3:** 提交(若有遗留)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
|
||||||
|
|
||||||
|
### 第 2 期:积分轨
|
||||||
|
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
|
||||||
|
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
|
||||||
|
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
|
||||||
|
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
|
||||||
|
- 测试:拦截、扣费、充值进积分、管理员调整。
|
||||||
|
|
||||||
|
### 第 3 期:A 消费明细
|
||||||
|
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
|
||||||
|
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
|
||||||
|
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
|
||||||
|
|
||||||
|
### 第 4 期:B 充值档位
|
||||||
|
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
|
||||||
|
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
|
||||||
|
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
|
||||||
|
|
||||||
|
### 第 5 期:C 预警
|
||||||
|
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
|
||||||
|
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
|
||||||
|
|
||||||
|
### 第 6 期:D 审计 + 对账
|
||||||
|
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
|
||||||
|
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自审
|
||||||
|
|
||||||
|
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
|
||||||
|
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
|
||||||
|
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 2 期:积分轨(细化 TDD)
|
||||||
|
|
||||||
|
文件结构:
|
||||||
|
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
|
||||||
|
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
|
||||||
|
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
|
||||||
|
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
|
||||||
|
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
|
||||||
|
|
||||||
|
### Task 6:积分轨核心 apply_points_delta + points_balance
|
||||||
|
|
||||||
|
**Files:** Modify `src/services/billing/ledger.rs`
|
||||||
|
|
||||||
|
- [ ] **Step1 失败测试**(ledger.rs tests mod):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
// 首次 +100(充值)
|
||||||
|
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
|
||||||
|
assert_eq!(bal, 100.0);
|
||||||
|
// 再 -30(消费)
|
||||||
|
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
|
||||||
|
assert_eq!(bal2, 70.0);
|
||||||
|
// user_credits 缓存 = 70
|
||||||
|
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
|
||||||
|
assert_eq!(cached, 70.0);
|
||||||
|
// ledger 两行
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
|
||||||
|
|
||||||
|
- [ ] **Step3 实现**(ledger.rs,tests 之前):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Current points balance for a user (0 if no row yet).
|
||||||
|
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
|
||||||
|
let bal: Option<f64> = sqlx::query_scalar(
|
||||||
|
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?;
|
||||||
|
Ok(bal.unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
|
||||||
|
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
|
||||||
|
/// in a single transaction. Returns the new balance.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn apply_points_delta(
|
||||||
|
db: &SqlitePool,
|
||||||
|
user_id: i64,
|
||||||
|
delta: f64,
|
||||||
|
entry_type: &str,
|
||||||
|
model: Option<&str>,
|
||||||
|
operator: Option<&str>,
|
||||||
|
ref_type: Option<&str>,
|
||||||
|
ref_id: Option<&str>,
|
||||||
|
) -> AppResult<f64> {
|
||||||
|
ensure_ledger_schema(db).await?;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
let current: f64 = sqlx::query_scalar(
|
||||||
|
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let next = current + delta;
|
||||||
|
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
points_balance = ?,
|
||||||
|
points_total_recharged = points_total_recharged + ?,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
)
|
||||||
|
.bind(user_id).bind(next).bind(total_recharged_inc)
|
||||||
|
.bind(next).bind(total_recharged_inc)
|
||||||
|
.execute(&mut *tx).await?;
|
||||||
|
record_ledger_entry(&mut tx, LedgerEntry {
|
||||||
|
user_id, track: "points", entry_type,
|
||||||
|
amount: delta, balance_after: next,
|
||||||
|
ref_type, ref_id, model, operator, note: None,
|
||||||
|
}).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
|
||||||
|
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
|
||||||
|
|
||||||
|
### Task 7:工作流积分单价 config
|
||||||
|
|
||||||
|
**Files:** Modify `src/config.rs`
|
||||||
|
|
||||||
|
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
|
||||||
|
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
|
||||||
|
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
|
||||||
|
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
|
||||||
|
|
||||||
|
### Task 8:工作流执行前置拦截 + 成功扣积分
|
||||||
|
|
||||||
|
**Files:** Modify `src/workflow/executor.rs`
|
||||||
|
|
||||||
|
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
|
||||||
|
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
|
||||||
|
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
|
||||||
|
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
|
||||||
|
|
||||||
|
- [ ] **Step2** 确认 FAIL。
|
||||||
|
|
||||||
|
- [ ] **Step3 实现**:
|
||||||
|
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
|
||||||
|
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
|
||||||
|
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
|
||||||
|
|
||||||
|
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
|
||||||
|
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
|
||||||
|
|
||||||
|
### Task 9:comfy 充值改加积分 + admin 积分调整
|
||||||
|
|
||||||
|
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
|
||||||
|
|
||||||
|
- [ ] **Step1 失败测试**:
|
||||||
|
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
|
||||||
|
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
|
||||||
|
- [ ] **Step2** FAIL。
|
||||||
|
- [ ] **Step3 实现**:
|
||||||
|
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
|
||||||
|
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
|
||||||
|
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
|
||||||
|
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
|
||||||
|
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
|
||||||
|
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
|
||||||
|
|
||||||
|
### Task 10:第 2 期全量回归
|
||||||
|
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 3 期:A 消费明细(细化 TDD)
|
||||||
|
|
||||||
|
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
|
||||||
|
|
||||||
|
### Task 11:ledger 查询函数(明细 + 聚合)
|
||||||
|
|
||||||
|
**Files:** Modify `src/services/billing/ledger.rs`
|
||||||
|
|
||||||
|
- [ ] **Step1 失败测试**(ledger.rs tests mod):
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_and_summarize_user_ledger() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
|
||||||
|
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
|
||||||
|
// 明细:user 9 两行,track=points
|
||||||
|
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
// 聚合:points 轨 consume 合计 -10
|
||||||
|
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
|
||||||
|
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
|
||||||
|
|
||||||
|
- [ ] **Step3 实现**(ledger.rs,tests 之前):
|
||||||
|
```rust
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
|
||||||
|
pub async fn list_user_ledger(
|
||||||
|
db: &SqlitePool, user_id: i64,
|
||||||
|
track: Option<&str>, entry_type: Option<&str>,
|
||||||
|
limit: i64, offset: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
|
ensure_ledger_schema(db).await?;
|
||||||
|
let limit = limit.clamp(1, 500);
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
|
||||||
|
FROM credit_ledger
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (?1_track IS NULL OR track = ?1_track)
|
||||||
|
AND (?1_type IS NULL OR entry_type = ?1_type)
|
||||||
|
ORDER BY id DESC LIMIT ? OFFSET ?"
|
||||||
|
.replace("?1_track", "?2").replace("?1_type", "?3")
|
||||||
|
)
|
||||||
|
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
|
||||||
|
.fetch_all(db).await?;
|
||||||
|
use sqlx::Row;
|
||||||
|
Ok(rows.iter().map(|r| json!({
|
||||||
|
"track": r.try_get::<String,_>("track").unwrap_or_default(),
|
||||||
|
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
|
||||||
|
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
|
||||||
|
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
|
||||||
|
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
|
||||||
|
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
|
||||||
|
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
|
||||||
|
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
|
||||||
|
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
|
||||||
|
})).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
|
||||||
|
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
|
||||||
|
ensure_ledger_schema(db).await?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
|
||||||
|
FROM credit_ledger WHERE user_id = ?
|
||||||
|
GROUP BY track, entry_type ORDER BY track, entry_type"
|
||||||
|
).bind(user_id).fetch_all(db).await?;
|
||||||
|
use sqlx::Row;
|
||||||
|
Ok(rows.iter().map(|r| json!({
|
||||||
|
"track": r.try_get::<String,_>("track").unwrap_or_default(),
|
||||||
|
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
|
||||||
|
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
|
||||||
|
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
|
||||||
|
})).collect())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
|
||||||
|
|
||||||
|
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
|
||||||
|
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
|
||||||
|
|
||||||
|
### Task 12:user 侧 billing 接口
|
||||||
|
|
||||||
|
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
|
||||||
|
|
||||||
|
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
|
||||||
|
- [ ] **Step2** FAIL/编译失败。
|
||||||
|
- [ ] **Step3 实现**:
|
||||||
|
```rust
|
||||||
|
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
|
||||||
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
|
let rows = crate::services::billing::ledger::list_user_ledger(
|
||||||
|
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
|
||||||
|
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
|
||||||
|
Ok(Json(json!({ "success": true, "records": rows })))
|
||||||
|
}
|
||||||
|
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
|
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
|
||||||
|
Ok(Json(json!({ "success": true, "summary": agg })))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
|
||||||
|
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
|
||||||
|
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
|
||||||
|
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
|
||||||
|
|
||||||
|
### Task 13:前端 api + UsageRecords 双轨展示
|
||||||
|
|
||||||
|
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
|
||||||
|
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
|
||||||
|
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
|
||||||
|
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
|
||||||
|
|
||||||
|
### Task 14:第3期回归
|
||||||
|
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
|
||||||
@@ -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,149 @@
|
|||||||
|
# 计费闭环系统设计
|
||||||
|
|
||||||
|
日期:2026-06-16
|
||||||
|
状态:设计已获批,待写实现计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
|
||||||
|
|
||||||
|
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
|
||||||
|
|
||||||
|
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
|
||||||
|
|
||||||
|
## 核心模型:双轨额度
|
||||||
|
|
||||||
|
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
|
||||||
|
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
|
||||||
|
|
||||||
|
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| `user_id` | INTEGER NOT NULL | 所属用户 |
|
||||||
|
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||||
|
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
|
||||||
|
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
|
||||||
|
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
|
||||||
|
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
|
||||||
|
| `ref_id` | TEXT | 关联记录 id/public_id |
|
||||||
|
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
|
||||||
|
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
|
||||||
|
| `note` | TEXT | 备注 |
|
||||||
|
| `created_at` | TEXT DEFAULT (datetime('now')) | |
|
||||||
|
|
||||||
|
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
|
||||||
|
|
||||||
|
### 新表 `user_credits`(积分余额缓存)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` | INTEGER PK | |
|
||||||
|
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
|
||||||
|
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
|
||||||
|
| `updated_at` | TEXT | |
|
||||||
|
|
||||||
|
### 新表 `recharge_tiers`(充值档位,admin 可配)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||||
|
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||||
|
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
|
||||||
|
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
|
||||||
|
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
|
||||||
|
| `title` | TEXT | 档位标题 |
|
||||||
|
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
|
||||||
|
| `enabled` | INTEGER DEFAULT 1 | 启用 |
|
||||||
|
|
||||||
|
### 余额派生策略:ledger + 缓存双写
|
||||||
|
|
||||||
|
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
|
||||||
|
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
|
||||||
|
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
|
||||||
|
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
|
||||||
|
|
||||||
|
## 双轨记账接入
|
||||||
|
|
||||||
|
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
|
||||||
|
|
||||||
|
### USD 轨
|
||||||
|
|
||||||
|
| 变动 | 现有代码 | 改造 |
|
||||||
|
|---|---|---|
|
||||||
|
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
|
||||||
|
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
|
||||||
|
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
|
||||||
|
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
|
||||||
|
|
||||||
|
### 积分轨(基本新建)
|
||||||
|
|
||||||
|
| 变动 | 现状 | 新增 |
|
||||||
|
|---|---|---|
|
||||||
|
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
|
||||||
|
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
|
||||||
|
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
|
||||||
|
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
|
||||||
|
|
||||||
|
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
|
||||||
|
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
|
||||||
|
|
||||||
|
## 积分消费规则:A+C 混合
|
||||||
|
|
||||||
|
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
|
||||||
|
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
|
||||||
|
|
||||||
|
## 四块功能
|
||||||
|
|
||||||
|
### A 消费明细(用户侧,明细+聚合)
|
||||||
|
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
|
||||||
|
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
|
||||||
|
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
|
||||||
|
|
||||||
|
### B 充值档位(本期可配置,订阅后期)
|
||||||
|
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
|
||||||
|
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
|
||||||
|
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
|
||||||
|
|
||||||
|
### C 超额 + 预警(两轨硬拦 + 预警)
|
||||||
|
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
|
||||||
|
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
|
||||||
|
- Dashboard/充值页/视频助手:余额低显示预警条。
|
||||||
|
|
||||||
|
### D 审计对账(管理员侧,全量流水)
|
||||||
|
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
|
||||||
|
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
|
||||||
|
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
|
||||||
|
|
||||||
|
## 错误处理(不变量)
|
||||||
|
|
||||||
|
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
|
||||||
|
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
|
||||||
|
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
|
||||||
|
- comfy 充值改向积分:config 开关控制,可回滚。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
|
||||||
|
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
|
||||||
|
|
||||||
|
## 实现顺序(分 6 期,每期可独立上线)
|
||||||
|
|
||||||
|
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
|
||||||
|
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
|
||||||
|
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
|
||||||
|
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
|
||||||
|
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
|
||||||
|
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
|
||||||
|
|
||||||
|
## 新增清单小结
|
||||||
|
|
||||||
|
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
|
||||||
|
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
|
||||||
|
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
|
||||||
|
- **cron**:Comfy 余额对账校准任务。
|
||||||
@@ -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');
|
||||||
+148
@@ -14,6 +14,8 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comfyui: ComfyUIConfig,
|
pub comfyui: ComfyUIConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub pixelle_video: PixelleVideoConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub payment: PaymentConfig,
|
pub payment: PaymentConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ pub struct ComfyUIConfig {
|
|||||||
pub money_printer_video_cost: f64,
|
pub money_printer_video_cost: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub digital_human_video_cost: f64,
|
pub digital_human_video_cost: f64,
|
||||||
|
#[serde(default = "default_workflow_points_cost")]
|
||||||
|
pub workflow_points_cost: f64,
|
||||||
#[serde(default = "default_comfyui_docker_image")]
|
#[serde(default = "default_comfyui_docker_image")]
|
||||||
pub docker_image: String,
|
pub docker_image: String,
|
||||||
#[serde(default = "default_comfyui_container_name")]
|
#[serde(default = "default_comfyui_container_name")]
|
||||||
@@ -77,9 +81,34 @@ pub struct ComfyUIConfig {
|
|||||||
pub agent_model: String,
|
pub agent_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct PixelleVideoConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub api_base_url: Option<String>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
#[serde(default = "default_pixelle_backend_provider")]
|
||||||
|
pub backend_provider: String,
|
||||||
|
pub frame_template: String,
|
||||||
|
pub media_workflow: Option<String>,
|
||||||
|
pub tts_workflow: Option<String>,
|
||||||
|
pub runninghub_media_workflow: Option<String>,
|
||||||
|
pub runninghub_tts_workflow: Option<String>,
|
||||||
|
pub mode: String,
|
||||||
|
pub n_scenes: i64,
|
||||||
|
pub video_fps: i64,
|
||||||
|
pub poll_interval_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)]
|
||||||
pub struct PaymentConfig {
|
pub struct PaymentConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: PaymentProvider,
|
||||||
pub public_base_url: Option<String>,
|
pub public_base_url: Option<String>,
|
||||||
pub product_name_prefix: String,
|
pub product_name_prefix: String,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
@@ -90,8 +119,31 @@ pub struct PaymentConfig {
|
|||||||
pub max_pending_orders: i64,
|
pub max_pending_orders: i64,
|
||||||
pub stripe_secret_key: Option<String>,
|
pub stripe_secret_key: Option<String>,
|
||||||
pub stripe_webhook_secret: Option<String>,
|
pub stripe_webhook_secret: Option<String>,
|
||||||
|
pub comfy_customer_token: Option<String>,
|
||||||
|
pub comfy_customer_token_endpoint: Option<String>,
|
||||||
|
pub comfy_firebase_api_key: Option<String>,
|
||||||
|
pub comfy_oauth_client_id: Option<String>,
|
||||||
|
pub comfy_oauth_client_secret: Option<String>,
|
||||||
|
pub comfy_oauth_refresh_token: Option<String>,
|
||||||
|
pub comfy_customer_api_base_url: Option<String>,
|
||||||
|
pub comfy_checkout_endpoint: Option<String>,
|
||||||
pub success_path: String,
|
pub success_path: String,
|
||||||
pub cancel_path: String,
|
pub cancel_path: String,
|
||||||
|
/// When true, confirming a comfy_cloud recharge grants workflow points
|
||||||
|
/// (amount_usd * usd_to_points_rate) instead of crediting the USD pool.
|
||||||
|
#[serde(default = "default_comfy_recharge_to_points")]
|
||||||
|
pub comfy_recharge_to_points: bool,
|
||||||
|
/// Points granted per 1 USD when comfy_recharge_to_points is enabled.
|
||||||
|
#[serde(default = "default_usd_to_points_rate")]
|
||||||
|
pub usd_to_points_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PaymentProvider {
|
||||||
|
#[default]
|
||||||
|
ComfyCloud,
|
||||||
|
Stripe,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone, Default)]
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
@@ -113,6 +165,30 @@ fn default_comfyui_container_name() -> String {
|
|||||||
"boosapi-comfyui-workflow".to_string()
|
"boosapi-comfyui-workflow".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_pixelle_backend_provider() -> 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 {
|
||||||
|
10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_comfy_recharge_to_points() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_usd_to_points_rate() -> f64 {
|
||||||
|
100.0
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ComfyUIConfig {
|
impl Default for ComfyUIConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -120,6 +196,7 @@ impl Default for ComfyUIConfig {
|
|||||||
backend: ComfyUIBackend::Boosapi,
|
backend: ComfyUIBackend::Boosapi,
|
||||||
money_printer_video_cost: 0.0,
|
money_printer_video_cost: 0.0,
|
||||||
digital_human_video_cost: 0.0,
|
digital_human_video_cost: 0.0,
|
||||||
|
workflow_points_cost: 10.0,
|
||||||
docker_image: default_comfyui_docker_image(),
|
docker_image: default_comfyui_docker_image(),
|
||||||
container_name: default_comfyui_container_name(),
|
container_name: default_comfyui_container_name(),
|
||||||
install_dir: None,
|
install_dir: None,
|
||||||
@@ -135,10 +212,34 @@ impl Default for ComfyUIConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for PixelleVideoConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
api_base_url: Some("http://127.0.0.1:8000".to_string()),
|
||||||
|
api_key: None,
|
||||||
|
backend_provider: default_pixelle_backend_provider(),
|
||||||
|
frame_template: "1080x1920/image_default.html".to_string(),
|
||||||
|
media_workflow: None,
|
||||||
|
tts_workflow: None,
|
||||||
|
runninghub_media_workflow: Some("runninghub/video_wan2.1_fusionx.json".to_string()),
|
||||||
|
runninghub_tts_workflow: Some("runninghub/tts_edge.json".to_string()),
|
||||||
|
mode: "fixed".to_string(),
|
||||||
|
n_scenes: 5,
|
||||||
|
video_fps: 30,
|
||||||
|
poll_interval_secs: 5,
|
||||||
|
timeout_secs: 900,
|
||||||
|
config_path: default_pixelle_config_path(),
|
||||||
|
compose_dir: default_pixelle_compose_dir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for PaymentConfig {
|
impl Default for PaymentConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
provider: PaymentProvider::ComfyCloud,
|
||||||
public_base_url: None,
|
public_base_url: None,
|
||||||
product_name_prefix: "BossAI 账户充值".to_string(),
|
product_name_prefix: "BossAI 账户充值".to_string(),
|
||||||
currency: "usd".to_string(),
|
currency: "usd".to_string(),
|
||||||
@@ -149,8 +250,55 @@ impl Default for PaymentConfig {
|
|||||||
max_pending_orders: 3,
|
max_pending_orders: 3,
|
||||||
stripe_secret_key: None,
|
stripe_secret_key: None,
|
||||||
stripe_webhook_secret: None,
|
stripe_webhook_secret: None,
|
||||||
|
comfy_customer_token: None,
|
||||||
|
comfy_customer_token_endpoint: None,
|
||||||
|
comfy_firebase_api_key: None,
|
||||||
|
comfy_oauth_client_id: None,
|
||||||
|
comfy_oauth_client_secret: None,
|
||||||
|
comfy_oauth_refresh_token: None,
|
||||||
|
comfy_customer_api_base_url: None,
|
||||||
|
comfy_checkout_endpoint: None,
|
||||||
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
|
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
|
||||||
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
|
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
|
||||||
|
comfy_recharge_to_points: true,
|
||||||
|
usd_to_points_rate: 100.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 4000,
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
cors_origins: vec!["*".to_string()],
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".to_string(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "test-secret".to_string(),
|
||||||
|
jwt_expiry_hours: 72,
|
||||||
|
auth_token: "test-admin-token".to_string(),
|
||||||
|
proxy_token: "sk-test-proxy".to_string(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 300,
|
||||||
|
first_byte_timeout_secs: 60,
|
||||||
|
max_retries: 3,
|
||||||
|
proxy_log_retention_days: 30,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
pixelle_video: PixelleVideoConfig::default(),
|
||||||
|
payment: PaymentConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
+61
-7
@@ -1,4 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
auth::jwt::validate_jwt,
|
||||||
db::models::setting::Setting,
|
db::models::setting::Setting,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
AppState,
|
||||||
@@ -16,15 +17,18 @@ 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;
|
||||||
|
|
||||||
/// Middleware to require admin authentication via the configured admin API token.
|
/// Middleware to require admin authentication via the configured admin API token
|
||||||
|
/// or a JWT belonging to a user with role='admin'.
|
||||||
async fn require_admin_auth(
|
async fn require_admin_auth(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -37,8 +41,16 @@ async fn require_admin_auth(
|
|||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
if auth_header != state.config.auth.auth_token {
|
if auth_header == state.config.auth.auth_token {
|
||||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
return Ok(next.run(request).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, auth_header)
|
||||||
|
.map_err(|_| AppError::Unauthorized("Invalid admin token".into()))?;
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin account is required for this route".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
@@ -164,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",
|
||||||
@@ -177,6 +194,22 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
.delete(users::delete_user),
|
.delete(users::delete_user),
|
||||||
)
|
)
|
||||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
|
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
|
||||||
|
.route(
|
||||||
|
"/api/admin/user-balances",
|
||||||
|
get(users::list_user_balances),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/pixelle/connection",
|
||||||
|
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/users/{id}/balance/adjust",
|
||||||
|
post(users::adjust_user_balance),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/users/{id}/credits/adjust",
|
||||||
|
post(users::adjust_user_credits),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/users/{id}/keys",
|
"/api/admin/users/{id}/keys",
|
||||||
get(users::list_user_keys).post(users::create_user_key),
|
get(users::list_user_keys).post(users::create_user_key),
|
||||||
@@ -199,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))
|
||||||
@@ -354,6 +388,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -419,7 +455,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_routes_accept_config_token_and_reject_admin_jwt() {
|
async fn admin_routes_accept_config_token_and_admin_jwt_reject_user_jwt() {
|
||||||
let state = settings_state().await;
|
let state = settings_state().await;
|
||||||
let app = routes(state.clone()).with_state(state.clone());
|
let app = routes(state.clone()).with_state(state.clone());
|
||||||
|
|
||||||
@@ -436,8 +472,10 @@ mod tests {
|
|||||||
.expect("token request");
|
.expect("token request");
|
||||||
assert_eq!(token_response.status(), StatusCode::OK);
|
assert_eq!(token_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let admin_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1)
|
// Admin-role JWT is accepted: admins signed in at /user/login (which now
|
||||||
.expect("admin jwt");
|
// mints an admin JWT) can reach admin routes without the global token.
|
||||||
|
let admin_jwt =
|
||||||
|
generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1).expect("admin jwt");
|
||||||
let jwt_response = app
|
let jwt_response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -449,7 +487,23 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("jwt request");
|
.expect("jwt request");
|
||||||
assert_eq!(jwt_response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(jwt_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Non-admin (user-role) JWT is rejected with 403.
|
||||||
|
let user_jwt =
|
||||||
|
generate_jwt(&state.config.auth.jwt_secret, "2", "user", 1).expect("user jwt");
|
||||||
|
let user_jwt_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/settings/auth/info")
|
||||||
|
.header("Authorization", format!("Bearer {user_jwt}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("user jwt request");
|
||||||
|
assert_eq!(user_jwt_response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
let missing_response = app
|
let missing_response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+322
-2
@@ -4,6 +4,7 @@ use crate::{
|
|||||||
auth::password::hash_password,
|
auth::password::hash_password,
|
||||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -238,17 +239,18 @@ pub async fn create_user(
|
|||||||
|
|
||||||
let user_id = result.last_insert_rowid();
|
let user_id = result.last_insert_rowid();
|
||||||
|
|
||||||
// Auto-generate downstream API key
|
// Auto-generate downstream API key with the default starter credit.
|
||||||
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
||||||
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&key_name)
|
.bind(&key_name)
|
||||||
.bind(&key)
|
.bind(&key)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(true)
|
.bind(true)
|
||||||
|
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -708,6 +710,232 @@ pub async fn update_user_key(
|
|||||||
Ok(Json(json!({ "data": updated })))
|
Ok(Json(json!({ "data": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User balance management (admin) ────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListBalancesQuery {
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub page_size: Option<i64>,
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/user-balances — list users with their credit pool
|
||||||
|
/// (summed max_cost / used_cost across their downstream keys).
|
||||||
|
pub async fn list_user_balances(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListBalancesQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
let q = query.q.unwrap_or_default().trim().to_string();
|
||||||
|
let q_like = format!("%{}%", q);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
u.email AS email,
|
||||||
|
u.role AS role,
|
||||||
|
u.status AS status,
|
||||||
|
COALESCE(SUM(COALESCE(k.used_cost, 0.0)), 0.0) AS used_cost,
|
||||||
|
COALESCE(SUM(CASE WHEN k.max_cost IS NULL THEN 0.0 ELSE k.max_cost END), 0.0) AS limited_max_cost,
|
||||||
|
SUM(CASE WHEN k.id IS NULL THEN 0 WHEN k.max_cost IS NULL THEN 1 ELSE 0 END) AS unlimited_key_count,
|
||||||
|
COUNT(k.id) AS key_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
|
||||||
|
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM users
|
||||||
|
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)",
|
||||||
|
)
|
||||||
|
.bind(&q)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q_like)
|
||||||
|
.bind(&q)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<Value> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
use sqlx::Row;
|
||||||
|
let used: f64 = row.try_get("used_cost").unwrap_or(0.0);
|
||||||
|
let limited_max: f64 = row.try_get("limited_max_cost").unwrap_or(0.0);
|
||||||
|
let unlimited: i64 = row.try_get("unlimited_key_count").unwrap_or(0);
|
||||||
|
let remaining = if unlimited > 0 {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::from((limited_max - used).max(0.0))
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"userId": row.try_get::<i64, _>("user_id").unwrap_or(0),
|
||||||
|
"username": row.try_get::<Option<String>, _>("username").ok().flatten(),
|
||||||
|
"email": row.try_get::<Option<String>, _>("email").ok().flatten(),
|
||||||
|
"role": row.try_get::<Option<String>, _>("role").ok().flatten(),
|
||||||
|
"status": row.try_get::<Option<String>, _>("status").ok().flatten(),
|
||||||
|
"keyCount": row.try_get::<i64, _>("key_count").unwrap_or(0),
|
||||||
|
"usedCost": used,
|
||||||
|
"maxCost": if unlimited > 0 { Value::Null } else { Value::from(limited_max) },
|
||||||
|
"remainingCost": remaining,
|
||||||
|
"unlimitedKeyCount": unlimited,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": items,
|
||||||
|
"meta": {
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": ((total + page_size - 1) / page_size).max(1),
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AdjustBalanceRequest {
|
||||||
|
/// Amount to add (positive) or remove (negative) from the user's credit pool.
|
||||||
|
pub delta: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/users/{id}/balance/adjust — add or remove credit on the
|
||||||
|
/// user's primary (lowest-id) downstream key by bumping its max_cost.
|
||||||
|
pub async fn adjust_user_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<AdjustBalanceRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let delta = payload.delta;
|
||||||
|
if !delta.is_finite() || delta == 0.0 {
|
||||||
|
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
|
||||||
|
|
||||||
|
let key: Option<(i64, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||||
|
"SELECT id, max_cost, used_cost
|
||||||
|
FROM downstream_api_keys
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some((key_id, max_cost, used_cost)) = key else {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"该用户没有可调整额度的 API Key(请先为其创建 Key)".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let used = used_cost.unwrap_or(0.0).max(0.0);
|
||||||
|
let current = max_cost.unwrap_or(used).max(used);
|
||||||
|
// Never let the limit drop below what's already been spent.
|
||||||
|
let next_max = (current + delta).max(used);
|
||||||
|
let remaining = (next_max - used).max(0.0);
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE downstream_api_keys
|
||||||
|
SET max_cost = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(next_max)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::services::billing::ledger::record_ledger_entry(
|
||||||
|
&mut tx,
|
||||||
|
crate::services::billing::ledger::LedgerEntry {
|
||||||
|
user_id: id,
|
||||||
|
track: "usd",
|
||||||
|
entry_type: "admin_adjust",
|
||||||
|
amount: delta,
|
||||||
|
balance_after: remaining,
|
||||||
|
ref_type: Some("manual"),
|
||||||
|
ref_id: None,
|
||||||
|
model: None,
|
||||||
|
operator: Some("admin"),
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
user_id = id,
|
||||||
|
key_id, delta, next_max, "admin adjusted user balance"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"userId": id,
|
||||||
|
"keyId": key_id,
|
||||||
|
"maxCost": next_max,
|
||||||
|
"usedCost": used,
|
||||||
|
"remainingCost": remaining,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/users/{id}/credits/adjust — add or remove workflow points
|
||||||
|
/// on the user's points pool (`user_credits`), writing a points/admin_adjust
|
||||||
|
/// ledger entry. Mirrors `adjust_user_balance` but for the points track.
|
||||||
|
pub async fn adjust_user_credits(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<AdjustBalanceRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let delta = payload.delta;
|
||||||
|
if !delta.is_finite() || delta == 0.0 {
|
||||||
|
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
|
||||||
|
let new_bal = crate::services::billing::ledger::apply_points_delta(
|
||||||
|
&state.db,
|
||||||
|
id,
|
||||||
|
delta,
|
||||||
|
"admin_adjust",
|
||||||
|
None,
|
||||||
|
Some("admin"),
|
||||||
|
Some("manual"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(user_id = id, delta, new_bal, "admin adjusted user credits");
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"userId": id,
|
||||||
|
"pointsBalance": new_bal,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -800,6 +1028,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -847,4 +1077,94 @@ mod tests {
|
|||||||
.expect("read role");
|
.expect("read role");
|
||||||
assert_eq!(role, "admin");
|
assert_eq!(role, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
||||||
|
let state = test_state().await;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger schema");
|
||||||
|
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Spender")
|
||||||
|
.bind("spender@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO downstream_api_keys (name, key, user_id, max_cost, used_cost)
|
||||||
|
VALUES (?, ?, ?, 10.0, 0.0)",
|
||||||
|
)
|
||||||
|
.bind("primary")
|
||||||
|
.bind("sk-spender")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert key");
|
||||||
|
|
||||||
|
adjust_user_balance(
|
||||||
|
State(state.clone()),
|
||||||
|
Path(user_id),
|
||||||
|
Json(AdjustBalanceRequest { delta: 5.0 }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adjust balance");
|
||||||
|
|
||||||
|
let row: (String, String, f64) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount FROM credit_ledger
|
||||||
|
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger row");
|
||||||
|
assert_eq!(row, ("usd".into(), "admin_adjust".into(), 5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adjust_user_credits_writes_points_admin_adjust_ledger() {
|
||||||
|
let state = test_state().await;
|
||||||
|
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger schema");
|
||||||
|
|
||||||
|
let user_id = sqlx::query(
|
||||||
|
"INSERT INTO users (username, email, password_hash, role, status)
|
||||||
|
VALUES (?, ?, ?, 'user', 'active')",
|
||||||
|
)
|
||||||
|
.bind("Pointer")
|
||||||
|
.bind("pointer@example.test")
|
||||||
|
.bind("hash")
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("insert user")
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
let response = adjust_user_credits(
|
||||||
|
State(state.clone()),
|
||||||
|
Path(user_id),
|
||||||
|
Json(AdjustBalanceRequest { delta: 50.0 }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("adjust credits");
|
||||||
|
|
||||||
|
assert_eq!(response.0["success"], true);
|
||||||
|
assert_eq!(response.0["data"]["pointsBalance"], 50.0);
|
||||||
|
|
||||||
|
let row: (String, String, f64) = sqlx::query_as(
|
||||||
|
"SELECT track, entry_type, amount FROM credit_ledger
|
||||||
|
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await
|
||||||
|
.expect("ledger row");
|
||||||
|
assert_eq!(row, ("points".into(), "admin_adjust".into(), 50.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user