Compare commits
24 Commits
37c44a7942
...
2e0cd3d161
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e0cd3d161 | |||
| 8a3c7b4191 | |||
| 7f7d2fe7fc | |||
| 064965d350 | |||
| 2c01e7672b | |||
| a40d9c2027 | |||
| 556adb429c | |||
| 39be7a5a0a | |||
| 0a5270a2cc | |||
| edc495ac01 | |||
| 156dbfda64 | |||
| 18d9b96cac | |||
| 2b0901eb62 | |||
| 1173e3af38 | |||
| 23234b96ca | |||
| 5f651755d8 | |||
| de3416cf3b | |||
| 76f35a7126 | |||
| 5e91f98ae0 | |||
| 60410f29a1 | |||
| 7e43c10e2d | |||
| 92a825fd15 | |||
| 2dd9ef95dd | |||
| 39fa8827da |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/target
|
||||
/result
|
||||
/.direnv
|
||||
/.yoi/dev
|
||||
.worktree
|
||||
*.local*
|
||||
.env
|
||||
|
|
|
|||
1
.yoi/.gitignore
vendored
1
.yoi/.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
/memory/
|
||||
tickets/.ticket-backend.lock
|
||||
/workspace.db*
|
||||
tickets
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
title: "効果的な Memory システム設計・検証"
|
||||
state: "active"
|
||||
created_at: "2026-06-20T15:16:00Z"
|
||||
updated_at: "2026-07-17T18:10:00Z"
|
||||
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR", "00001KXMEZNYC", "00001KXMK7YMC", "00001KXNYXNM6", "00001KXRM6G0G", "00001KXMK846H"]
|
||||
updated_at: "2026-07-17T23:10:00Z"
|
||||
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR", "00001KXMEZNYC", "00001KXMK7YMC", "00001KXNYXNM6", "00001KXRM6G0G", "00001KXS56AS5", "00001KXMK846H"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
---
|
||||
title: 'session-explore feature付きextract workerを実装する'
|
||||
state: 'planning'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-16T04:34:01Z'
|
||||
updated_at: '2026-07-17T18:10:00Z'
|
||||
updated_at: '2026-07-18T01:35:46Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T01:12:22Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
|
|
|||
10
.yoi/tickets/00001KXMK7YMC/resolution.md
Normal file
10
.yoi/tickets/00001KXMK7YMC/resolution.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
Implemented the session-explore-backed extract worker.
|
||||
|
||||
The extract worker now receives an immutable `SessionReferenceView` snapshot and only the `builtin:session-explore` tool surface. It can search/read bounded evidence, stage one flat candidate per `stage_candidate` call with resolved source anchors, and finish cleanly with `finish_extraction` including the no-candidate path.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
- `yoi ticket doctor`
|
||||
|
|
@ -57,4 +57,101 @@ Rescoped from broad Overview-first extract implementation to the worker implemen
|
|||
Design updated: abandon the old batch `write_extracted` / `decisions` / `discussions` / `attempts` / `requests` staging direction. Extract now stages flat candidate records with `stage_candidate` and finishes with `finish_extraction`. The older dependency on 00001KXNYXNM6 is historical; the active schema dependency is 00001KXRM6G0G.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T01:12:22Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T01:12:22Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T01:12:22Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T01:12:22Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T01:35:46Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented the session-explore-backed memory extract worker.
|
||||
|
||||
Changes:
|
||||
- Added `builtin:session-explore` with `search_evidence`, `read_evidence`, `stage_candidate`, and `finish_extraction`.
|
||||
- Extract worker now builds an immutable `SessionReferenceView` at extract start and enables only session-explore tools for the internal worker.
|
||||
- Added overview-first / evidence-index-second extract input rendering over the reference view.
|
||||
- `stage_candidate` writes one flat staging record per call, resolving evidence ids into bounded staging evidence and `SourceEvidenceRef` anchors.
|
||||
- `finish_extraction` supports normal no-candidate completion with `staged_count = 0`.
|
||||
- Updated `memory_extract_system.md` to the session-explore / stage / finish contract.
|
||||
- Added a single-candidate staging helper in `memory::extract::staging`.
|
||||
- Updated extract-trigger tests to use `finish_extraction` and added session-explore unit tests.
|
||||
|
||||
Validation:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
- `yoi ticket doctor`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T01:35:46Z from: inprogress to: done reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `done`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T01:35:46Z from: done to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T01:35:46Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Implemented the session-explore-backed extract worker.
|
||||
|
||||
The extract worker now receives an immutable `SessionReferenceView` snapshot and only the `builtin:session-explore` tool surface. It can search/read bounded evidence, stage one flat candidate per `stage_candidate` call with resolved source anchors, and finish cleanly with `finish_extraction` including the no-candidate path.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
- `yoi ticket doctor`
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
0
.yoi/tickets/00001KXS56AS5/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXS56AS5/artifacts/.gitkeep
Normal file
132
.yoi/tickets/00001KXS56AS5/item.md
Normal file
132
.yoi/tickets/00001KXS56AS5/item.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
title: 'Session reference viewを共通基盤として実装する'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-17T23:04:40Z'
|
||||
updated_at: '2026-07-17T23:27:20Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-17T23:15:15Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Memory extract は runtime 側で session snapshot から filtered view を作り、`session-explore` tools で evidence を読んで flat staging candidates を作る予定である。一方、compaction も既に session history から summary input を作り、`search_session_log` / `read_session_items` で詳細を探索する形に近い。
|
||||
|
||||
この2つは目的が違うが、共通して「session history から人間/LLMが扱いやすい参照 view を作る」「bounded に探索・読取する」という基盤を必要とする。extract 専用でも compact 専用でもない中立的な共通基盤として `Session reference view` を実装する。
|
||||
|
||||
関連:
|
||||
|
||||
- Objective `00001KVJSMQXZ`
|
||||
- Ticket `00001KXMK7YMC` — session-explore feature付きextract workerを実装する
|
||||
- worker compaction implementation: `crates/worker/src/compact/worker.rs`
|
||||
|
||||
## 実装意図
|
||||
|
||||
`Session reference view` は、session history の full raw dump ではなく、internal worker が必要な範囲を辿るための host-created immutable view である。
|
||||
|
||||
目的:
|
||||
|
||||
- User / Assistant text outputs から semantic overview を作る。
|
||||
- Tool calls / tool results / file refs などを evidence index として分離する。
|
||||
- compact / extract などの internal worker が同じ read/search substrate を使えるようにする。
|
||||
- read/search output を bounded にし、raw tool result 全文や reasoning を無制限に渡さない。
|
||||
- extract では source anchor / evidence id を staging record へ接続できるようにする。
|
||||
- compact では summary worker が必要な session detail を安全に読めるようにする。
|
||||
|
||||
## 対象領域
|
||||
|
||||
候補:
|
||||
|
||||
- `crates/worker/src/compact/worker.rs`
|
||||
- 既存の `build_summary_input` / `search_session_log` / `read_session_items` に相当する projection / read 機能。
|
||||
- `crates/memory/src/extract/input.rs`
|
||||
- extract 用 Overview-first input。
|
||||
- `crates/worker/src/internal_worker.rs`
|
||||
- internal worker runner から参照 view を渡す将来接続点。
|
||||
- 新規 module 候補:
|
||||
- `crates/worker/src/session_reference.rs`
|
||||
- または extract/compact から独立した crate-local module。
|
||||
|
||||
## 実装要件
|
||||
|
||||
- immutable `SessionReferenceView` 相当の型を実装する。
|
||||
- view 作成時点の session items / `entry_range` を固定する。
|
||||
- foreground history の live mutation を直接読まない。
|
||||
- view は少なくとも次を提供する。
|
||||
- overview projection。
|
||||
- evidence index。
|
||||
- bounded search。
|
||||
- bounded read by stable id / `entry_range`。
|
||||
- overview projection は User / Assistant text outputs を主軸にする。
|
||||
- Progress message と final response は同じ text output として扱う。
|
||||
- tool result 全文や reasoning は overview に混ぜない。
|
||||
- evidence index は user / assistant / system / tool などの session reference kind で参照可能にする。
|
||||
- evidence id を持つ。
|
||||
- `entry_range` を持つ。
|
||||
- kind を持つ。
|
||||
- tool entry は input / output のどちらを対象にするか区別できる。
|
||||
- short label / summary を持つ。
|
||||
- bounded read は、指定範囲の message / tool summary / bounded excerpt を返す。
|
||||
- token / byte / item count の上限を持つ。
|
||||
- over-limit 時は truncation metadata を返す。
|
||||
- search は初期実装では text match / substring / simple case-insensitive query でよい。
|
||||
- jq-like structural query は初期実装に含めない。
|
||||
- ただし kind / id / `entry_range` で絞れる API にする。
|
||||
- tool kind では input / output / both を検索対象として指定できる。
|
||||
- extract 向けに、evidence id から source anchor を作れる情報を保持する。
|
||||
- compact 向けに、既存 `search_session_log` / `read_session_items` 相当を置き換えられる構造にする。
|
||||
- 既存 compaction behavior を壊さない。
|
||||
|
||||
## Access model 方針
|
||||
|
||||
初期 API は、LLM が扱いやすい **id / `entry_range` based access** を中心にする。
|
||||
|
||||
- Overview item には `overview_id` / `message_seq` のような安定 id を付ける。
|
||||
- Progress message だけを特別扱いして `prog y` で指定するより、User / Assistant text output を同じ overview item として並べる。
|
||||
- ただし表示上は、長い turn の中で progress が複数あることが分かるように label を持たせる。
|
||||
- 範囲指定は `overview_id` / `evidence_id` / `entry_range` を基本にする。
|
||||
- `turn x, prog y ~ turn x, prog z` のような UI 表示は可能だが、tool input の canonical key にはしない。
|
||||
- `kind` は role と evidence type を統合した filter として扱う。
|
||||
- `user`
|
||||
- `assistant`
|
||||
- `system`
|
||||
- `tool`
|
||||
- tool kind では `tool_part` を指定できる。
|
||||
- `input`: tool call name / arguments を検索・読取対象にする。
|
||||
- `output`: tool result summary / bounded content excerpt を検索・読取対象にする。
|
||||
- `both`: input / output の両方を対象にする。
|
||||
|
||||
推奨 tool/API shape:
|
||||
|
||||
```text
|
||||
search_reference(query, kind?, tool_part?, limit?) -> ids + labels + entry_ranges
|
||||
read_reference(id | entry_range, include_tools?, tool_part?, max_items?, max_bytes?) -> bounded entries + truncation metadata
|
||||
resolve_reference(id | entry_range) -> source anchor
|
||||
```
|
||||
|
||||
探索は初期段階では text search と typed filters の組み合わせにする。jq-like structural query は強力だが、LLM tool input として複雑になりやすいため後回しにする。tool input / output を別々に検索したい需要は初期 filter として扱う。
|
||||
|
||||
## 非目標
|
||||
|
||||
- Protocol method として external client に公開しない。
|
||||
- vector search / embedding search は実装しない。
|
||||
- jq-like full structural query language は実装しない。
|
||||
- raw tool result 全文を unbounded に読ませない。
|
||||
- Progress message 専用の別 item kind を導入しない。
|
||||
- compaction の全面 rewrite はこの Ticket では行わない。
|
||||
- `session-explore` feature tools 本体はこの Ticket では実装しない。共通基盤だけを作る。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `SessionReferenceView` 相当の共通型がある。
|
||||
- User / Assistant text outputs から overview projection を作れる。
|
||||
- tool calls / tool results を evidence index として参照できる。
|
||||
- search filter は `kind` で user / assistant / system / tool を扱える。
|
||||
- tool kind では input / output / both を検索・読取対象として選べる。
|
||||
- bounded search / read がある。
|
||||
- read output に truncation metadata がある。
|
||||
- extract が source anchor を作るための evidence id / entry range / kind を取得できる。
|
||||
- compact 側の既存 search/read と置き換え可能な形になっている。
|
||||
- relevant unit tests が追加されている。
|
||||
- `cargo test -p worker` または該当 crate の同等テストが通る。
|
||||
- code 変更として `nix build .#yoi` が通る。
|
||||
11
.yoi/tickets/00001KXS56AS5/resolution.md
Normal file
11
.yoi/tickets/00001KXS56AS5/resolution.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Implemented `SessionReferenceView` common substrate.
|
||||
|
||||
The Worker crate now has an immutable session reference view that can build User/Assistant overview items, index user/assistant/system/tool references, search with kind and tool input/output filters, perform bounded reads with truncation metadata, and resolve source refs for future extract staging anchors. Compaction search now uses this common view; read-session behavior remains unchanged for compatibility.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
87
.yoi/tickets/00001KXS56AS5/thread.md
Normal file
87
.yoi/tickets/00001KXS56AS5/thread.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-17T23:04:40Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:15:15Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:15:15Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-17T23:27:20Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented `SessionReferenceView` common substrate.
|
||||
|
||||
Changes:
|
||||
- Added `crates/worker/src/session_reference.rs` with immutable view construction over a session history slice.
|
||||
- The view produces User/Assistant overview items and a searchable evidence index over user/assistant/system/tool references.
|
||||
- Added text search with `kind` filter and tool input/output/both filtering via `tool_part`.
|
||||
- Added bounded read by id or `entry_range` with truncation metadata.
|
||||
- Added source-ref resolution by evidence id for future extract staging anchors.
|
||||
- Rewired compaction `search_session_log` to search through `SessionReferenceView` while leaving `read_session_items` behavior unchanged for now.
|
||||
|
||||
Validation:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:27:20Z from: inprogress to: done reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `done`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-17T23:27:20Z from: done to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-17T23:27:20Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Implemented `SessionReferenceView` common substrate.
|
||||
|
||||
The Worker crate now has an immutable session reference view that can build User/Assistant overview items, index user/assistant/system/tool references, search with kind and tool input/output filters, perform bounded reads with truncation metadata, and resolve source refs for future extract staging anchors. Compaction search now uses this common view; read-session behavior remains unchanged for compatibility.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXS81AMT/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXS81AMT/artifacts/.gitkeep
Normal file
39
.yoi/tickets/00001KXS81AMT/item.md
Normal file
39
.yoi/tickets/00001KXS81AMT/item.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
title: 'Compactionのsession探索toolsをSessionReferenceViewへ移行する'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-17T23:54:22Z'
|
||||
updated_at: '2026-07-18T00:02:34Z'
|
||||
assignee: null
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`SessionReferenceView` は extract / compact の共通基盤として導入されたが、直後の実装では compaction の `search_session_log` のみが view を使い、`read_session_items` は旧 compact 専用 formatter / direct item traversal のままだった。
|
||||
|
||||
既存 compact も explore tool 群を利用するものに寄せるため、この Ticket では compaction の session read/search tools を `SessionReferenceView` へ統合する。
|
||||
|
||||
## 実装要件
|
||||
|
||||
- compaction の `search_session_log` は `SessionReferenceView::search` を使う。
|
||||
- compaction の `read_session_items` は `SessionReferenceView::read` を使う。
|
||||
- `read_session_items` の existing `mode = compact | full` semantics は維持する。
|
||||
- compact: tool arguments / tool result content を omitted にする。
|
||||
- full: bounded に tool arguments / tool result content を読める。
|
||||
- 旧 compact 専用の session search / format helper を削除する。
|
||||
- `SessionReferenceView` 側に read detail mode を追加し、compact / full を表現できるようにする。
|
||||
- 既存 compact tests を維持または更新する。
|
||||
|
||||
## 非目標
|
||||
|
||||
- compaction prompt を大きく変えない。
|
||||
- compact worker の tool 名を変更しない。
|
||||
- extract の `session-explore` feature 本体はこの Ticket では実装しない。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `search_session_log` と `read_session_items` がどちらも `SessionReferenceView` を経由する。
|
||||
- compact mode で raw tool content が出ない。
|
||||
- full mode で bounded tool content が読める。
|
||||
- `cargo test -p worker compact::worker` が通る。
|
||||
- `cargo test -p worker` が通る。
|
||||
- code 変更として `nix build .#yoi` が通る。
|
||||
11
.yoi/tickets/00001KXS81AMT/resolution.md
Normal file
11
.yoi/tickets/00001KXS81AMT/resolution.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Compaction session exploration now uses `SessionReferenceView` for both search and read paths.
|
||||
|
||||
`read_session_items` delegates to `SessionReferenceView::read`, preserving compact/full behavior through a new read detail mode. Old compact-specific search/format helpers were removed.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
68
.yoi/tickets/00001KXS81AMT/thread.md
Normal file
68
.yoi/tickets/00001KXS81AMT/thread.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-17T23:54:22Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T00:02:34Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Migrated compaction session exploration to `SessionReferenceView`.
|
||||
|
||||
Changes:
|
||||
- Added `ReadDetail` to `SessionReferenceView` so reads can preserve compact/full semantics.
|
||||
- Updated `read_session_items` to use `SessionReferenceView::read` with `ReadSelector::EntryRange`.
|
||||
- Kept compact mode omitting tool arguments / tool result content and full mode bounded.
|
||||
- Removed old compact-specific session search/format helpers.
|
||||
- `search_session_log` already used `SessionReferenceView::search` and remains on that path.
|
||||
|
||||
Validation:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T00:02:34Z from: inprogress to: done reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `done`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T00:02:34Z from: done to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T00:02:34Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Compaction session exploration now uses `SessionReferenceView` for both search and read paths.
|
||||
|
||||
`read_session_items` delegates to `SessionReferenceView::read`, preserving compact/full behavior through a new read detail mode. Old compact-specific search/format helpers were removed.
|
||||
|
||||
Validation passed:
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p worker compact::worker`
|
||||
- `cargo test -p worker session_reference`
|
||||
- `cargo test -p worker`
|
||||
- `cargo test -p memory`
|
||||
- `nix build .#yoi`
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXSHEWD1/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXSHEWD1/artifacts/.gitkeep
Normal file
42
.yoi/tickets/00001KXSHEWD1/item.md
Normal file
42
.yoi/tickets/00001KXSHEWD1/item.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
title: 'TUI backend runtime worker listを表面化する'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-07-18T02:39:04Z'
|
||||
updated_at: '2026-07-18T02:46:09Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T02:39:40Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
TUI を backend runtime の client として接続する経路は基礎実装があるが、ユーザーが通常導線として worker を一覧し、既存 worker を選んで接続/確認する入口が表面化していない。
|
||||
|
||||
直近の調査では、backend/runtime の worker summary/detail は REST で取れる一方、会話内容は observation WebSocket snapshot を読む必要があり、CLI/TUI から人間向けに扱いづらいことも確認された。
|
||||
|
||||
この Ticket では、まず TUI backend runtime client 導線の worker list 実装から着手し、現行 staging/extract 実装が runtime/embedded 経路で破綻していないかの確認も含める。
|
||||
|
||||
## 実装要件
|
||||
|
||||
- TUI を backend runtime client として使う導線で、runtime worker list をユーザーに見える形にする。
|
||||
- worker list は backend が公開している runtime/worker summary を authority とし、TUI 側で独自 scheduler や duplicate backend を作らない。
|
||||
- list item には少なくとも runtime id、worker id、label/profile、status/state、working directory summary を表示できること。
|
||||
- 既存 worker を選択して attach/read/inspect へ進めるための実装境界を整理する。今回の主作業は list で、attach/read の大規模実装は必要なら follow-up に分ける。
|
||||
- backend/runtime は local/private boundary を維持し、frontend だけ外部 bind できる前提を壊さない。
|
||||
- staging/extract 実装について、embedded/runtime 経路で staging write abstraction が破綻していないかを確認し、必要なら小修正する。
|
||||
|
||||
## 非目標
|
||||
|
||||
- worker scheduler を TUI に新設しない。
|
||||
- backend/runtime REST API を無秩序に増やさない。必要な場合は bounded/read-only な endpoint として理由を明確にする。
|
||||
- TUI の全面 redesign はしない。
|
||||
- extract candidate tuning はこの Ticket の主目的ではない。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- backend runtime client mode で worker list が見える、またはそのための CLI/TUI 実装差分が明確に入る。
|
||||
- list は backend/runtime authority 由来の worker identity を使う。
|
||||
- 実装者が既存 worker `arc/3` 相当を確認し、動作確認結果を Ticket thread に残す。
|
||||
- staging/extract の current implementation について、runtime/embedded 経路での確認結果を Ticket thread に残す。
|
||||
- `cargo fmt --check` と relevant tests が通る。
|
||||
- code/resource 変更がある場合は `nix build .#yoi` を通す。
|
||||
76
.yoi/tickets/00001KXSHEWD1/thread.md
Normal file
76
.yoi/tickets/00001KXSHEWD1/thread.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T02:39:04Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T02:39:40Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T02:39:40Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T02:39:40Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T02:39:40Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: hare at: 2026-07-18T02:39:40Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
Delegate implementation/recon to existing backend runtime Worker `arc/3`.
|
||||
|
||||
Requested focus:
|
||||
- Surface the TUI-as-backend-runtime-client worker list route first.
|
||||
- Inspect current TUI/backend runtime client implementation and make the smallest implementation that exposes worker list from backend/runtime authority.
|
||||
- Include a staging/extract implementation check: verify the recent session-explore extract worker and flat staging writer still work through the runtime/embedded workspace abstraction; record findings and fix only concrete breakage.
|
||||
- Leave attach/read/transcript UX as follow-up if it is larger than worker list.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: hare at: 2026-07-18T02:46:09Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Paused before delegating work to Worker `arc/3`.
|
||||
|
||||
Reason:
|
||||
- The current live Yoi session/runtime appears to be an older version that can emit obsolete/noisy memory staging records.
|
||||
- Existing `.yoi/memory/_staging/*.json` from this session was quarantined without reading contents.
|
||||
- No implementation prompt was sent to Worker `arc/3`; the Ticket is only prepared for continuation after restarting into the current build/runtime.
|
||||
|
||||
Resume condition:
|
||||
- Restart with the current Yoi build/runtime/session.
|
||||
- Confirm `.yoi/memory/_staging/` is clean or only contains new-format records.
|
||||
- Then move this Ticket back through ready/queued/inprogress and delegate/implement.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep
Normal file
31
.yoi/tickets/00001KXSJXMR5/item.md
Normal file
31
.yoi/tickets/00001KXSJXMR5/item.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
title: 'Enable Ticket tools in default profile'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T03:04:36Z'
|
||||
updated_at: '2026-07-18T08:32:27Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T03:05:10Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
通常の `builtin:default` Worker では Ticket feature が無効で、`TicketList` / `TicketShow` などの typed Ticket tools が露出しない。実装作業では Ticket の前提・受け入れ条件・thread を読めないと正しく作業できないため、default profile でも Ticket tools を有効化する。
|
||||
|
||||
Orchestrator 固有の relation / orchestration plan tools は引き続き `ticket_orchestration` で分離し、default profile では有効化しない。
|
||||
|
||||
## 要件
|
||||
|
||||
- `resources/profiles/default.dcdl` の Ticket feature を有効化する。
|
||||
- builtin default profile artifact の Ticket feature も同じ既定にする。
|
||||
- setup wizard が生成する `user:default` profile でも Ticket feature を有効化する。
|
||||
- `ticket_orchestration` は default では無効のままにする。
|
||||
- 裸の `WorkerManifestConfig::builtin_defaults()` の feature default は profile 既定とは別なので変更しない。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `builtin:default` profile resolution で `feature.ticket.enabled == true` になる。
|
||||
- `builtin:default` profile resolution で `feature.ticket.access == lifecycle` になる。
|
||||
- `builtin:default` profile resolution で `feature.ticket_orchestration.enabled == false` のままになる。
|
||||
- setup model が生成する default profile に `[feature.ticket] enabled = true` が含まれる。
|
||||
- 関連する profile/setup focused tests が通る。
|
||||
1
.yoi/tickets/00001KXSJXMR5/resolution.md
Normal file
1
.yoi/tickets/00001KXSJXMR5/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations.
|
||||
85
.yoi/tickets/00001KXSJXMR5/thread.md
Normal file
85
.yoi/tickets/00001KXSJXMR5/thread.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T03:04:36Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T03:05:10Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:05:10Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:05:10Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:05:10Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T03:07:25Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
`builtin:default` profile と setup wizard 生成の `user:default` profile で Ticket feature を有効化した。
|
||||
|
||||
変更点:
|
||||
- `resources/profiles/default.dcdl` の `feature.ticket.enabled` を `true` に変更。
|
||||
- builtin default profile artifact の `feature.ticket.enabled` を `true` に変更。
|
||||
- setup wizard が生成する default profile の `[feature.ticket] enabled` を `true` に変更。
|
||||
- builtin default profile resolution / setup profile generation のテスト期待値を追加。
|
||||
- `ticket_orchestration` は default では無効のまま維持。
|
||||
- `WorkerManifestConfig::builtin_defaults()` の裸の feature default は変更していない。
|
||||
|
||||
検証:
|
||||
- `cargo test -p manifest builtin_default_resolves_without_external_evaluator --quiet`
|
||||
- `cargo test -p manifest builtin_role_profiles_preserve_role_tool_policy --quiet`
|
||||
- `cargo test -p tui write_default_profile_config_creates_registry_and_profile --quiet`
|
||||
- `cargo test -p worker installs_ticket_tools_when_default_root_is_usable --quiet`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T08:32:27Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T08:32:27Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep
Normal file
33
.yoi/tickets/00001KXSKP1AB/item.md
Normal file
33
.yoi/tickets/00001KXSKP1AB/item.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
title: 'Add development process switch script'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T03:17:55Z'
|
||||
updated_at: '2026-07-18T08:32:27Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T03:18:16Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
開発中の backend / runtime / workspace frontend は、手動で `cargo run` や `deno` task を main worktree から起動している。作業ブランチを切った worktree に付け替える時、既存 process を安全に止めて同じ port で起動し直す手順が必要になる。
|
||||
|
||||
この worker 自体が同じ開発 process 群に依存している可能性があるため、実装時には stop/restart/start の実行は行わず、静的検証に留める。
|
||||
|
||||
## 要件
|
||||
|
||||
- `scripts/dev-workspace.sh` を追加する。
|
||||
- 引数で `start` / `stop` / `restart` を選べる。
|
||||
- `start` は runtime / backend / frontend をこの checkout から起動する。
|
||||
- `stop` は runtime / backend / frontend を停止する。
|
||||
- `restart` は frontend を触らず、runtime / backend だけ停止・起動する。
|
||||
- frontend は `0.0.0.0` bind で起動する。
|
||||
- pid/log は repository-local な runtime directory に保存し、生成物は git 管理対象外にする。
|
||||
- port listener の付け替えに対応する。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `bash -n scripts/dev-workspace.sh` が通る。
|
||||
- `scripts/dev-workspace.sh --help` が安全に使い方を表示する。
|
||||
- start/stop/restart は実装するが、この作業中には実行しない。
|
||||
- runtime/backend/frontend の command と default bind/port が script 内で確認できる。
|
||||
1
.yoi/tickets/00001KXSKP1AB/resolution.md
Normal file
1
.yoi/tickets/00001KXSKP1AB/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`.
|
||||
98
.yoi/tickets/00001KXSKP1AB/thread.md
Normal file
98
.yoi/tickets/00001KXSKP1AB/thread.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T03:17:55Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T03:18:16Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:18:16Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:18:16Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T03:18:16Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T03:20:37Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
`script/dev-workspace.sh` ではなく repository root の `scripts/dev-workspace.sh` として開発 process 管理 script を追加した。
|
||||
|
||||
実装内容:
|
||||
- `start` / `stop` / `restart` / `status` action を追加。
|
||||
- `start` は runtime, backend, frontend をこの checkout から起動する。
|
||||
- `stop` は runtime, backend, frontend を止める。
|
||||
- `restart` は frontend を触らず、runtime/backend だけ止めて起動し直す。
|
||||
- frontend は既定で `0.0.0.0:5173` bind。
|
||||
- backend は既定で `127.0.0.1:8787`、runtime は `127.0.0.1:38800`。
|
||||
- pid/log は `.yoi/dev/{pids,logs}` に保存し、`.gitignore` に `/.yoi/dev` を追加。
|
||||
- 既存 listener がいる場合、port listener PID を止めてからこの checkout の process を起動する。
|
||||
- managed process は pidfile の pid/process group を使って止める。pidfile 管理外の既存 listener は、caller の process group を巻き込まないよう PID 単位で止める。
|
||||
|
||||
確認:
|
||||
- `bash -n scripts/dev-workspace.sh`
|
||||
- `scripts/dev-workspace.sh --help`
|
||||
- `scripts/dev-workspace.sh status`
|
||||
- `git diff --check`
|
||||
|
||||
実行しなかったこと:
|
||||
- `scripts/dev-workspace.sh start`
|
||||
- `scripts/dev-workspace.sh stop`
|
||||
- `scripts/dev-workspace.sh restart`
|
||||
|
||||
理由:
|
||||
- 現在の worker 自体が既存 backend/runtime/frontend process 群に依存している可能性があり、停止・再起動するとこのセッションが落ちるため。
|
||||
|
||||
現状確認:
|
||||
- frontend listener pid は `/home/hare/Projects/yoi/web/workspace` 由来。
|
||||
- backend/runtime listener pid は `/home/hare/Projects/yoi` 由来。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T08:32:27Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T08:32:27Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep
Normal file
28
.yoi/tickets/00001KXT5FEFR/item.md
Normal file
28
.yoi/tickets/00001KXT5FEFR/item.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: 'Limit workdir dirty source check to HEAD selector'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T08:28:54Z'
|
||||
updated_at: '2026-07-18T08:32:27Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T08:29:31Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Runtime workdir materializer は `selector` を `git rev-parse <selector>^{commit}` で commit に解決し、`git worktree add --detach` で detached worktree を作る。明示 branch / tag / commit selector の場合、source worktree の未コミット変更は生成される detached worktree には入らないため、dirty source を理由に拒否する必要はない。
|
||||
|
||||
一方で `HEAD` selector は現在の source worktree の状態を暗黙に参照する導線なので、既存の dirty source safety check を維持する。
|
||||
|
||||
## 要件
|
||||
|
||||
- workdir materialization の dirty source check は実質 selector が `HEAD` の時だけ行う。
|
||||
- 明示 branch selector では source repository が dirty でも commit 解決と detached worktree 作成を許す。
|
||||
- `HEAD` selector の dirty source rejection は維持する。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `selector = HEAD` で source repository が dirty の場合、`working_directory_dirty_source_rejected` になる。
|
||||
- `selector = <branch>` で source repository が dirty の場合、workdir materialization が成功する。
|
||||
- 明示 branch selector で作った workdir は dirty source の未コミットファイルを含まない。
|
||||
- focused worker-runtime tests が通る。
|
||||
1
.yoi/tickets/00001KXT5FEFR/resolution.md
Normal file
1
.yoi/tickets/00001KXT5FEFR/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes.
|
||||
82
.yoi/tickets/00001KXT5FEFR/thread.md
Normal file
82
.yoi/tickets/00001KXT5FEFR/thread.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T08:28:54Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T08:29:31Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:29:31Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:29:31Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:29:31Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T08:32:27Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
workdir materialization の dirty source check を `selector == "HEAD"` の場合だけ実行するようにした。
|
||||
|
||||
変更点:
|
||||
- `LocalGitWorktreeMaterializer::materialize` で selector を先に確定。
|
||||
- selector が `HEAD` の時だけ `git status --porcelain` による dirty source rejection を行う。
|
||||
- 明示 branch selector では source worktree が dirty でも `git rev-parse <selector>^{commit}` で commit 解決し、detached worktree を作る。
|
||||
- branch selector + dirty source の回帰テストを追加し、未コミットファイルが materialized worktree に入らないことを確認。
|
||||
|
||||
検証:
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store branch_selector_allows_dirty_source_materialization --quiet`
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store dirty_source_is_rejected_by_materialization --quiet`
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store local_git_repo_materializes_detached_worktree_under_runtime_root --quiet`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T08:32:27Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T08:32:27Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXT5ZDRE/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXT5ZDRE/artifacts/.gitkeep
Normal file
31
.yoi/tickets/00001KXT5ZDRE/item.md
Normal file
31
.yoi/tickets/00001KXT5ZDRE/item.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
title: 'Run dev workspace process actions detached by default'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T08:37:37Z'
|
||||
updated_at: '2026-07-18T08:40:59Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T08:38:38Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`scripts/dev-workspace.sh` は backend/runtime/frontend の stop/start を扱うため、API worker が依存している process を foreground tool call 中に停止すると tool result 永続化前にセッションを壊す可能性がある。
|
||||
|
||||
呼び出し元が `start` / `stop` / `restart` を実行した時点では即座に戻り、実際の mutating action は detached scheduled job として後で走るようにする。
|
||||
|
||||
## 要件
|
||||
|
||||
- `start` / `stop` / `restart` は既定で detached background job として schedule する。
|
||||
- 既定 delay は 60 秒とし、呼び出し元が tool result を返す時間を確保する。
|
||||
- scheduled job の pid と log path を呼び出し元に表示する。
|
||||
- `status` / `help` は同期実行のままにする。
|
||||
- デバッグ用に foreground 実行へ戻せる環境変数を用意する。
|
||||
- 実装中に mutating action は実行しない。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `bash -n scripts/dev-workspace.sh` が通る。
|
||||
- `scripts/dev-workspace.sh --help` に detached 既定挙動と override が表示される。
|
||||
- `scripts/dev-workspace.sh status` は引き続き mutation なしで動く。
|
||||
- `start` / `stop` / `restart` の実処理分岐は内部 foreground mode 経由に限定され、通常呼び出しでは detached schedule だけを行う。
|
||||
1
.yoi/tickets/00001KXT5ZDRE/resolution.md
Normal file
1
.yoi/tickets/00001KXT5ZDRE/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
`scripts/dev-workspace.sh` now schedules `start`, `stop`, and `restart` as detached background jobs by default, returning schedule/log details immediately. `status` and `help` remain synchronous, and `YOI_DEV_WORKSPACE_FOREGROUND=1` preserves an explicit foreground/debug path.
|
||||
92
.yoi/tickets/00001KXT5ZDRE/thread.md
Normal file
92
.yoi/tickets/00001KXT5ZDRE/thread.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T08:37:37Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T08:38:38Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:38:38Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:38:38Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T08:38:38Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T08:40:59Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
`scripts/dev-workspace.sh` の mutating actions を既定で detached scheduled job として実行するように変更した。
|
||||
|
||||
変更点:
|
||||
- `start` / `stop` / `restart` は通常呼び出しでは実処理をせず、detached job を schedule して即時 return する。
|
||||
- 既定 delay は `YOI_DEV_ACTION_DELAY_SECONDS=60`。
|
||||
- 呼び出し元には `scheduled_action` / `scheduled_pid` / `scheduled_after_seconds` / `scheduled_log` を表示する。
|
||||
- scheduled job は `YOI_DEV_WORKSPACE_FOREGROUND=1` を付けて同じ script を再入実行し、そこで実際の start/stop/restart を行う。
|
||||
- `status` / `help` は同期実行のまま。
|
||||
- デバッグ用に `YOI_DEV_WORKSPACE_FOREGROUND=1` で foreground 実行できるようにした。
|
||||
- help に detached 既定挙動と環境変数 override を追記した。
|
||||
|
||||
検証:
|
||||
- `bash -n scripts/dev-workspace.sh`
|
||||
- `scripts/dev-workspace.sh --help >/dev/null`
|
||||
- `scripts/dev-workspace.sh status >/dev/null`
|
||||
- `git diff --check`
|
||||
|
||||
実行しなかったこと:
|
||||
- `scripts/dev-workspace.sh start`
|
||||
- `scripts/dev-workspace.sh stop`
|
||||
- `scripts/dev-workspace.sh restart`
|
||||
|
||||
理由:
|
||||
- mutating action は schedule 後に backend/runtime/frontend を止めるため、この作業中には実行しない。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T08:40:59Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T08:40:59Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
`scripts/dev-workspace.sh` now schedules `start`, `stop`, and `restart` as detached background jobs by default, returning schedule/log details immediately. `status` and `help` remain synchronous, and `YOI_DEV_WORKSPACE_FOREGROUND=1` preserves an explicit foreground/debug path.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXTB7H5N/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXTB7H5N/artifacts/.gitkeep
Normal file
28
.yoi/tickets/00001KXTB7H5N/item.md
Normal file
28
.yoi/tickets/00001KXTB7H5N/item.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: 'Document dev workspace process script usage'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T10:09:26Z'
|
||||
updated_at: '2026-07-18T10:10:43Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T10:09:33Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`scripts/dev-workspace.sh` は backend/runtime/frontend を停止・再起動する開発用スクリプトで、誤って foreground 実行や frontend restart を行うと API セッションやブラウザ接続に影響する。スクリプト本体を読むだけで安全な使い方と注意が分かるようにする。
|
||||
|
||||
## 要件
|
||||
|
||||
- スクリプト先頭コメントに基本コマンドを記載する。
|
||||
- `start` / `stop` / `restart` が既定で detached schedule されることを明記する。
|
||||
- `restart` は frontend を触らないことを明記する。
|
||||
- frontend も付け替える時は `start` / `stop` を使う注意を書く。
|
||||
- foreground override は通常使わない注意を書く。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `scripts/dev-workspace.sh` の先頭を読めば使い方と注意が分かる。
|
||||
- `bash -n scripts/dev-workspace.sh` が通る。
|
||||
- `scripts/dev-workspace.sh --help` が通る。
|
||||
- mutating action は実行しない。
|
||||
1
.yoi/tickets/00001KXTB7H5N/resolution.md
Normal file
1
.yoi/tickets/00001KXTB7H5N/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Added top-of-file usage and safety comments to `scripts/dev-workspace.sh`, documenting detached default behavior, restart/frontend semantics, foreground override risk, and scheduled log follow-up.
|
||||
85
.yoi/tickets/00001KXTB7H5N/thread.md
Normal file
85
.yoi/tickets/00001KXTB7H5N/thread.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T10:09:26Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T10:09:33Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T10:09:33Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T10:09:33Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T10:09:33Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T10:10:43Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
`scripts/dev-workspace.sh` の先頭に使い方と注意コメントを追加した。
|
||||
|
||||
追記内容:
|
||||
- `status` / `restart` / `start` / `stop` の基本用途。
|
||||
- `start` / `stop` / `restart` は既定で detached schedule されること。
|
||||
- `restart` は backend/runtime のみで frontend を触らないこと。
|
||||
- frontend を worktree 間で移す場合は `start` / `stop` を使うこと。
|
||||
- `YOI_DEV_WORKSPACE_FOREGROUND=1` は active API session 中に避けるべきこと。
|
||||
- detached action 後は `scheduled_log` を確認すること。
|
||||
|
||||
検証:
|
||||
- `bash -n scripts/dev-workspace.sh`
|
||||
- `scripts/dev-workspace.sh --help >/dev/null`
|
||||
- `scripts/dev-workspace.sh status >/dev/null`
|
||||
- `git diff --check`
|
||||
|
||||
mutating action (`start`/`stop`/`restart`) は実行していない。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T10:10:43Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T10:10:43Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Added top-of-file usage and safety comments to `scripts/dev-workspace.sh`, documenting detached default behavior, restart/frontend semantics, foreground override risk, and scheduled log follow-up.
|
||||
|
||||
|
||||
---
|
||||
0
.yoi/tickets/00001KXTKS0VG/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KXTKS0VG/artifacts/.gitkeep
Normal file
29
.yoi/tickets/00001KXTKS0VG/item.md
Normal file
29
.yoi/tickets/00001KXTKS0VG/item.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
title: 'Preserve Workdirs when Workers stop or delete'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-18T12:38:47Z'
|
||||
updated_at: '2026-07-18T12:49:12Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-18T12:39:18Z'
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Backend-managed Workdir は Worker とは独立した再利用可能 resource として扱う。現在は Worker stop/delete 時に runtime が Worker の working directory binding を `materializer.cleanup` してしまい、workspace-server 側の Workdir record だけが残って `corrupted` と表示される。
|
||||
|
||||
Worker lifecycle と Workdir lifecycle を分離し、Worker の削除は Workdir の占有を解放するだけにする。Workdir 実体の削除は明示的な Workdir cleanup/delete API に限定する。
|
||||
|
||||
## 要件
|
||||
|
||||
- Worker stop/delete では Workdir 実体を削除しない。
|
||||
- Worker spawn failure rollback では、その spawn request で新規 materialize した Workdir だけ cleanup する。
|
||||
- 既存 Workdir に bind した spawn failure では Workdir を cleanup しない。
|
||||
- Workdir cleanup API 経由の明示 cleanup は維持する。
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- `stop_worker` 後も Worker に bind されていた Workdir 実体が残る。
|
||||
- Worker spawn failure で既存 Workdir bind が使われた場合、Workdir 実体は残る。
|
||||
- Worker spawn failure で新規 materialize した場合だけ rollback cleanup される。
|
||||
- focused worker-runtime tests が通る。
|
||||
1
.yoi/tickets/00001KXTKS0VG/resolution.md
Normal file
1
.yoi/tickets/00001KXTKS0VG/resolution.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Worker stop/delete no longer cleans up Workdir materialization. Spawn failure rollback is limited to newly materialized Workdirs and uses Workdir cleanup by id so rollback does not leave corrupted records. Existing Workdir binds survive spawn failures.
|
||||
89
.yoi/tickets/00001KXTKS0VG/thread.md
Normal file
89
.yoi/tickets/00001KXTKS0VG/thread.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<!-- event: create author: "yoi ticket" at: 2026-07-18T12:38:47Z -->
|
||||
|
||||
## 作成
|
||||
|
||||
LocalTicketBackend によって作成されました。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: intake_summary author: hare at: 2026-07-18T12:39:18Z -->
|
||||
|
||||
## Intake summary
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T12:39:18Z from: planning to: ready reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Marked ready by `yoi ticket state`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T12:39:18Z from: ready to: queued reason: queued field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を `yoi ticket` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-18T12:39:18Z from: queued to: inprogress reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-18T12:49:12Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Worker lifecycle と Workdir lifecycle を分離した。
|
||||
|
||||
変更点:
|
||||
- `stop_worker` から `materializer.cleanup(binding)` を削除し、Worker stop/delete で Workdir 実体を削除しないようにした。
|
||||
- live execution map の `working_directory` field は stop cleanup 用だけだったため削除した。
|
||||
- `spawn_worker` の failure rollback 対象を「この spawn request で新規 materialize した binding」だけに限定した。
|
||||
- rollback は `cleanup(binding)` ではなく `cleanup_working_directory(id)` を使い、worktree 実体だけでなく record root も消して corrupted 残骸を残さないようにした。
|
||||
- 既存 Workdir bind の spawn failure では Workdir を cleanup しない。
|
||||
|
||||
追加テスト:
|
||||
- `stopping_and_deleting_worker_preserves_bound_working_directory`
|
||||
- `spawn_failure_with_existing_working_directory_preserves_workdir`
|
||||
- `spawn_failure_with_new_materialization_rolls_back_workdir_record`
|
||||
|
||||
検証:
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store stopping_and_deleting_worker_preserves_bound_working_directory --quiet`
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store spawn_failure_with_existing_working_directory_preserves_workdir --quiet`
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store spawn_failure_with_new_materialization_rolls_back_workdir_record --quiet`
|
||||
- `cargo test -p worker-runtime --features ws-server,fs-store worker_backend --quiet`
|
||||
- `cargo fmt --check`
|
||||
- `git diff --check`
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-18T12:49:12Z from: inprogress to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-18T12:49:12Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
Worker stop/delete no longer cleans up Workdir materialization. Spawn failure rollback is limited to newly materialized Workdirs and uses Workdir cleanup by id so rollback does not leave corrupted records. Existing Workdir binds survive spawn failures.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -19,8 +19,8 @@ use crate::plugin::PluginConfig;
|
|||
use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
|
||||
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
|
||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||
WebConfig, WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
|
|
@ -87,8 +87,6 @@ pub struct FeatureConfigPartial {
|
|||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||
}
|
||||
|
||||
|
|
@ -100,11 +98,6 @@ impl FeatureConfigPartial {
|
|||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||
workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
ticket_orchestration: merge_option(
|
||||
self.ticket_orchestration,
|
||||
other.ticket_orchestration,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||
}
|
||||
}
|
||||
|
|
@ -124,19 +117,24 @@ impl FeatureFlagConfigPartial {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct TicketFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub access: Option<TicketFeatureAccessConfig>,
|
||||
pub authoring: Option<bool>,
|
||||
pub thread: Option<bool>,
|
||||
pub intake: Option<bool>,
|
||||
pub orchestration_control: Option<bool>,
|
||||
}
|
||||
|
||||
impl TicketFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
access: other.access.or(self.access),
|
||||
authoring: other.authoring.or(self.authoring),
|
||||
thread: other.thread.or(self.thread),
|
||||
intake: other.intake.or(self.intake),
|
||||
orchestration_control: other.orchestration_control.or(self.orchestration_control),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -158,10 +156,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
ticket_orchestration: value
|
||||
.ticket_orchestration
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
plugins: value
|
||||
.plugins
|
||||
.map(FeatureFlagConfig::from)
|
||||
|
|
@ -190,7 +184,10 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
|||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
access: value.access.unwrap_or_default(),
|
||||
authoring: value.authoring.unwrap_or_default(),
|
||||
thread: value.thread.unwrap_or_default(),
|
||||
intake: value.intake.unwrap_or_default(),
|
||||
orchestration_control: value.orchestration_control.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +196,10 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
|||
fn from(value: TicketFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
access: Some(value.access),
|
||||
authoring: Some(value.authoring),
|
||||
thread: Some(value.thread),
|
||||
intake: Some(value.intake),
|
||||
orchestration_control: Some(value.orchestration_control),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -212,7 +212,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||
web: Some(value.web.into()),
|
||||
workers: Some(value.workers.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
||||
plugins: Some(value.plugins.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -1758,7 +1757,6 @@ worker_max_turns = 7
|
|||
assert!(!manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.workers.enabled);
|
||||
assert!(!manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.ticket_orchestration.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1770,10 +1768,10 @@ enabled = true
|
|||
|
||||
[feature.ticket]
|
||||
enabled = true
|
||||
access = "read_only"
|
||||
|
||||
[feature.ticket_orchestration]
|
||||
enabled = true
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -1803,11 +1801,10 @@ enabled = true
|
|||
.unwrap();
|
||||
assert!(manifest.feature.task.enabled);
|
||||
assert!(manifest.feature.ticket.enabled);
|
||||
assert_eq!(
|
||||
manifest.feature.ticket.access,
|
||||
TicketFeatureAccessConfig::ReadOnly
|
||||
);
|
||||
assert!(manifest.feature.ticket_orchestration.enabled);
|
||||
assert!(!manifest.feature.ticket.authoring);
|
||||
assert!(!manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(!manifest.feature.ticket.orchestration_control);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
}
|
||||
|
||||
|
|
@ -1820,14 +1817,18 @@ enabled = true
|
|||
|
||||
[feature.ticket]
|
||||
enabled = true
|
||||
access = "read_only"
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let upper = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[feature.ticket]
|
||||
access = "lifecycle"
|
||||
thread = true
|
||||
orchestration_control = true
|
||||
|
||||
[feature.web]
|
||||
enabled = true
|
||||
|
|
@ -1861,10 +1862,10 @@ enabled = true
|
|||
.unwrap();
|
||||
assert!(manifest.feature.memory.enabled);
|
||||
assert!(manifest.feature.ticket.enabled);
|
||||
assert_eq!(
|
||||
manifest.feature.ticket.access,
|
||||
TicketFeatureAccessConfig::Lifecycle
|
||||
);
|
||||
assert!(!manifest.feature.ticket.authoring);
|
||||
assert!(manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(manifest.feature.ticket.orchestration_control);
|
||||
assert!(manifest.feature.web.enabled);
|
||||
assert!(!manifest.feature.workers.enabled);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,8 +115,6 @@ pub struct FeatureConfig {
|
|||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub ticket_orchestration: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +126,6 @@ impl Default for FeatureConfig {
|
|||
web: FeatureFlagConfig::disabled(),
|
||||
workers: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
ticket_orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
}
|
||||
}
|
||||
|
|
@ -156,37 +153,18 @@ impl Default for FeatureFlagConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct TicketFeatureConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Which non-orchestration Ticket surface to expose when `enabled = true`.
|
||||
/// Orchestration-plan/relation tools are controlled independently by
|
||||
/// `[feature.ticket_orchestration].enabled`.
|
||||
#[serde(default)]
|
||||
pub access: TicketFeatureAccessConfig,
|
||||
}
|
||||
|
||||
impl Default for TicketFeatureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
access: TicketFeatureAccessConfig::Lifecycle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketFeatureAccessConfig {
|
||||
ReadOnly,
|
||||
Lifecycle,
|
||||
}
|
||||
|
||||
impl Default for TicketFeatureAccessConfig {
|
||||
fn default() -> Self {
|
||||
Self::Lifecycle
|
||||
}
|
||||
pub authoring: bool,
|
||||
#[serde(default)]
|
||||
pub thread: bool,
|
||||
#[serde(default)]
|
||||
pub intake: bool,
|
||||
#[serde(default)]
|
||||
pub orchestration_control: bool,
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
|
|
|
|||
|
|
@ -894,8 +894,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
|
|
@ -909,8 +907,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
|
|
@ -924,8 +920,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
|
|
@ -939,8 +933,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
|
|
@ -954,8 +946,6 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
|
|
@ -981,8 +971,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value {
|
|||
"memory": { "enabled": true },
|
||||
"web": { "enabled": true },
|
||||
"workers": { "enabled": true },
|
||||
"ticket": { "enabled": false, "access": "lifecycle" },
|
||||
"ticket_orchestration": { "enabled": false }
|
||||
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
||||
},
|
||||
"memory": {
|
||||
"extract_threshold": 50000,
|
||||
|
|
@ -1009,19 +998,26 @@ fn apply_role_profile(
|
|||
memory: bool,
|
||||
web: bool,
|
||||
workers: bool,
|
||||
ticket: bool,
|
||||
ticket_orchestration: bool,
|
||||
) {
|
||||
value["slug"] = serde_json::Value::String(slug.to_string());
|
||||
value["description"] = serde_json::Value::String(description.to_string());
|
||||
value["feature"] = serde_json::json!({
|
||||
"task": { "enabled": task },
|
||||
"memory": { "enabled": memory },
|
||||
"web": { "enabled": web },
|
||||
"workers": { "enabled": workers },
|
||||
"ticket": { "enabled": ticket, "access": "lifecycle" },
|
||||
"ticket_orchestration": { "enabled": ticket_orchestration }
|
||||
});
|
||||
value["feature"]["task"] = serde_json::json!({ "enabled": task });
|
||||
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
|
||||
value["feature"]["web"] = serde_json::json!({ "enabled": web });
|
||||
value["feature"]["workers"] = serde_json::json!({ "enabled": workers });
|
||||
let ticket = match slug {
|
||||
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
"intake" => {
|
||||
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||
}
|
||||
"orchestrator" => {
|
||||
serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true })
|
||||
}
|
||||
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
};
|
||||
value["feature"]["ticket"] = ticket;
|
||||
}
|
||||
|
||||
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
||||
|
|
@ -1441,12 +1437,16 @@ mod tests {
|
|||
let companion = resolve("companion");
|
||||
assert!(companion.feature.task.enabled);
|
||||
assert!(companion.feature.workers.enabled);
|
||||
assert!(!companion.feature.ticket.enabled);
|
||||
assert!(companion.scope.allow.is_empty());
|
||||
assert!(companion.scope.deny.is_empty());
|
||||
assert!(companion.delegation_scope.allow.is_empty());
|
||||
assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
assert!(companion.web.is_some());
|
||||
assert!(companion.feature.ticket.enabled);
|
||||
assert!(companion.feature.ticket.authoring);
|
||||
assert!(companion.feature.ticket.thread);
|
||||
assert!(!companion.feature.ticket.intake);
|
||||
assert!(!companion.feature.ticket.orchestration_control);
|
||||
assert_eq!(
|
||||
companion.compaction.as_ref().unwrap().threshold,
|
||||
Some(240000)
|
||||
|
|
@ -1468,18 +1468,26 @@ mod tests {
|
|||
assert!(intake.feature.task.enabled);
|
||||
assert!(!intake.feature.workers.enabled);
|
||||
assert!(intake.feature.ticket.enabled);
|
||||
assert!(intake.feature.ticket.enabled);
|
||||
assert!(intake.feature.ticket.authoring);
|
||||
assert!(intake.feature.ticket.thread);
|
||||
assert!(intake.feature.ticket.intake);
|
||||
assert!(!intake.feature.ticket.orchestration_control);
|
||||
assert!(intake.scope.allow.is_empty());
|
||||
assert!(intake.delegation_scope.allow.is_empty());
|
||||
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
assert!(intake.web.is_some());
|
||||
assert!(intake.compaction.is_some());
|
||||
assert!(!intake.feature.ticket_orchestration.enabled);
|
||||
|
||||
let orchestrator = resolve("orchestrator");
|
||||
assert!(orchestrator.feature.task.enabled);
|
||||
assert!(orchestrator.feature.workers.enabled);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(orchestrator.feature.ticket_orchestration.enabled);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(!orchestrator.feature.ticket.authoring);
|
||||
assert!(orchestrator.feature.ticket.thread);
|
||||
assert!(!orchestrator.feature.ticket.intake);
|
||||
assert!(orchestrator.feature.ticket.orchestration_control);
|
||||
assert!(orchestrator.scope.allow.is_empty());
|
||||
assert!(orchestrator.delegation_scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
|
|
@ -1497,11 +1505,21 @@ mod tests {
|
|||
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
assert!(coder.web.is_some());
|
||||
assert!(coder.compaction.is_some());
|
||||
|
||||
assert!(coder.feature.ticket.enabled);
|
||||
assert!(coder.feature.ticket.enabled);
|
||||
assert!(!coder.feature.ticket.authoring);
|
||||
assert!(coder.feature.ticket.thread);
|
||||
assert!(!coder.feature.ticket.intake);
|
||||
assert!(!coder.feature.ticket.orchestration_control);
|
||||
let reviewer = resolve("reviewer");
|
||||
assert!(reviewer.feature.task.enabled);
|
||||
assert!(!reviewer.feature.workers.enabled);
|
||||
assert!(!reviewer.feature.ticket.enabled);
|
||||
assert!(reviewer.feature.ticket.enabled);
|
||||
assert!(reviewer.feature.ticket.enabled);
|
||||
assert!(!reviewer.feature.ticket.authoring);
|
||||
assert!(reviewer.feature.ticket.thread);
|
||||
assert!(!reviewer.feature.ticket.intake);
|
||||
assert!(!reviewer.feature.ticket.orchestration_control);
|
||||
assert!(reviewer.scope.allow.is_empty());
|
||||
assert!(reviewer.delegation_scope.allow.is_empty());
|
||||
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
|
|
@ -1650,10 +1668,10 @@ enabled = true
|
|||
|
||||
[feature.ticket]
|
||||
enabled = true
|
||||
access = "read_only"
|
||||
|
||||
[feature.ticket_orchestration]
|
||||
enabled = false
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
orchestration_control = false
|
||||
"#,
|
||||
);
|
||||
let workspace = tmp.path().join("workspace");
|
||||
|
|
@ -1671,11 +1689,10 @@ enabled = false
|
|||
assert!(resolved.manifest.feature.web.enabled);
|
||||
assert!(resolved.manifest.feature.workers.enabled);
|
||||
assert!(resolved.manifest.feature.ticket.enabled);
|
||||
assert_eq!(
|
||||
resolved.manifest.feature.ticket.access,
|
||||
crate::TicketFeatureAccessConfig::ReadOnly
|
||||
);
|
||||
assert!(!resolved.manifest.feature.ticket_orchestration.enabled);
|
||||
assert!(!resolved.manifest.feature.ticket.authoring);
|
||||
assert!(!resolved.manifest.feature.ticket.thread);
|
||||
assert!(!resolved.manifest.feature.ticket.intake);
|
||||
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||
assert_eq!(
|
||||
resolved.manifest.delegation_scope.allow[0].target,
|
||||
workspace
|
||||
|
|
@ -1758,9 +1775,11 @@ worker_context_max_tokens = 68000
|
|||
resolved.manifest.model.ref_.as_deref(),
|
||||
Some("codex-oauth/gpt-5.5")
|
||||
);
|
||||
assert!(resolved.manifest.scope.allow.is_empty());
|
||||
assert!(resolved.manifest.delegation_scope.allow.is_empty());
|
||||
assert!(resolved.manifest.session.record_event_trace);
|
||||
assert!(resolved.manifest.feature.ticket.enabled);
|
||||
assert!(resolved.manifest.feature.ticket.authoring);
|
||||
assert!(resolved.manifest.feature.ticket.thread);
|
||||
assert!(!resolved.manifest.feature.ticket.intake);
|
||||
assert!(!resolved.manifest.feature.ticket.orchestration_control);
|
||||
assert_eq!(
|
||||
resolved.profile.as_ref().unwrap().name.as_deref(),
|
||||
Some("default")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pub use payload::{
|
|||
StagingRecord,
|
||||
};
|
||||
pub use pointer::{ExtractPointerPayload, fold_pointer};
|
||||
pub use staging::{StagingWriteResult, write_staging};
|
||||
pub use staging::{StagingWriteResult, write_staging, write_staging_candidate};
|
||||
pub use tool::{ExtractWorkerContext, write_extracted_tool};
|
||||
|
||||
/// session-store `LogEntry::Extension` で使う domain 名。
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ use std::path::PathBuf;
|
|||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::extract::payload::{ExtractedPayload, StagingRecord};
|
||||
use crate::schema::SourceRef;
|
||||
use crate::extract::payload::{
|
||||
ExtractedCandidate, ExtractedPayload, StagingEvidence, StagingRecord,
|
||||
};
|
||||
use crate::schema::{SourceEvidenceRef, SourceRef};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Filesystem result for a single staged candidate.
|
||||
|
|
@ -34,28 +36,46 @@ pub fn write_staging(
|
|||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let dir = layout.staging_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
let extract_run_id = Uuid::now_v7().to_string();
|
||||
let mut written = Vec::with_capacity(payload.candidates.len());
|
||||
|
||||
for candidate in payload.candidates {
|
||||
let id = Uuid::now_v7();
|
||||
let record = StagingRecord::from_candidate(
|
||||
id.to_string(),
|
||||
extract_run_id.clone(),
|
||||
written.push(write_staging_candidate(
|
||||
layout,
|
||||
source.clone(),
|
||||
&extract_run_id,
|
||||
candidate,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
pub fn write_staging_candidate(
|
||||
layout: &WorkspaceLayout,
|
||||
source: SourceRef,
|
||||
extract_run_id: &str,
|
||||
candidate: ExtractedCandidate,
|
||||
evidence: Vec<StagingEvidence>,
|
||||
source_refs: Vec<SourceEvidenceRef>,
|
||||
) -> io::Result<StagingWriteResult> {
|
||||
let dir = layout.staging_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
let id = Uuid::now_v7();
|
||||
let record = StagingRecord::from_candidate(
|
||||
id.to_string(),
|
||||
extract_run_id.to_string(),
|
||||
source,
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
);
|
||||
let path = dir.join(format!("{}.json", id));
|
||||
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
|
||||
fs::write(&path, bytes)?;
|
||||
written.push(StagingWriteResult { id, path });
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
Ok(StagingWriteResult { id, path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -511,17 +511,138 @@ impl NewTicket {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketFilter {
|
||||
pub state: Option<TicketWorkflowState>,
|
||||
pub struct TicketItemEdit {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<MarkdownText>,
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
impl TicketFilter {
|
||||
pub fn all() -> Self {
|
||||
Self { state: None }
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketDependencyCheck {
|
||||
pub ticket: TicketSummary,
|
||||
pub blockers: Vec<TicketRelationBlocker>,
|
||||
pub queue_guard: TicketQueueGuard,
|
||||
pub recommended_action: TicketWorkspaceNextAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TicketListState {
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
InProgress,
|
||||
Done,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl TicketListState {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"planning" => Some(Self::Planning),
|
||||
"ready" => Some(Self::Ready),
|
||||
"queued" => Some(Self::Queued),
|
||||
"inprogress" => Some(Self::InProgress),
|
||||
"done" => Some(Self::Done),
|
||||
"closed" => Some(Self::Closed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(state: TicketWorkflowState) -> Self {
|
||||
Self { state: Some(state) }
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Planning => "planning",
|
||||
Self::Ready => "ready",
|
||||
Self::Queued => "queued",
|
||||
Self::InProgress => "inprogress",
|
||||
Self::Done => "done",
|
||||
Self::Closed => "closed",
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_workflow_state(self, state: TicketWorkflowState) -> bool {
|
||||
match self {
|
||||
Self::Planning => state == TicketWorkflowState::Planning,
|
||||
Self::Ready => state == TicketWorkflowState::Ready,
|
||||
Self::Queued => state == TicketWorkflowState::Queued,
|
||||
Self::InProgress => state == TicketWorkflowState::InProgress,
|
||||
Self::Done => state == TicketWorkflowState::Done,
|
||||
Self::Closed => state == TicketWorkflowState::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketStateSelector {
|
||||
/// All non-closed workflow states: planning, ready, queued, inprogress, and done.
|
||||
Active,
|
||||
/// Every workflow state, including closed.
|
||||
All,
|
||||
/// An explicit set of list-query state tokens.
|
||||
States(BTreeSet<TicketListState>),
|
||||
}
|
||||
|
||||
impl Default for TicketStateSelector {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketListQuery {
|
||||
pub state: TicketStateSelector,
|
||||
}
|
||||
|
||||
impl Default for TicketListQuery {
|
||||
fn default() -> Self {
|
||||
Self::active()
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketListQuery {
|
||||
pub fn active() -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::Active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::All,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(state: TicketListState) -> Self {
|
||||
Self::states([state])
|
||||
}
|
||||
|
||||
pub fn states(states: impl IntoIterator<Item = TicketListState>) -> Self {
|
||||
Self {
|
||||
state: TicketStateSelector::States(states.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_state(&self, state: TicketWorkflowState) -> bool {
|
||||
match &self.state {
|
||||
TicketStateSelector::Active => state != TicketWorkflowState::Closed,
|
||||
TicketStateSelector::All => true,
|
||||
TicketStateSelector::States(states) => states
|
||||
.iter()
|
||||
.any(|query_state| query_state.matches_workflow_state(state)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_filter_label(&self) -> String {
|
||||
match &self.state {
|
||||
TicketStateSelector::Active => "active".to_string(),
|
||||
TicketStateSelector::All => "all".to_string(),
|
||||
TicketStateSelector::States(states) => states
|
||||
.iter()
|
||||
.map(|state| state.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -633,6 +754,415 @@ pub struct TicketRelationView {
|
|||
pub notices: Vec<TicketRelationNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketWorkspaceActionPriority {
|
||||
ReadyForQueue,
|
||||
ActiveWork,
|
||||
Background,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketWorkspaceNextAction {
|
||||
Clarify,
|
||||
QueueForOrchestrator,
|
||||
Close,
|
||||
WaitForOrchestrator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketWorkspaceRowKind {
|
||||
Planning,
|
||||
Ticket,
|
||||
Review,
|
||||
ActiveWork,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketWorkspaceStateOverlay {
|
||||
pub source: String,
|
||||
pub workflow_state: TicketWorkflowState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketQueueGuard {
|
||||
pub can_queue_for_orchestrator: bool,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketWorkspaceProjection {
|
||||
pub kind: TicketWorkspaceRowKind,
|
||||
pub priority: TicketWorkspaceActionPriority,
|
||||
pub next_action: Option<TicketWorkspaceNextAction>,
|
||||
pub visible_state: String,
|
||||
pub visible_overlay: Option<TicketWorkspaceStateOverlay>,
|
||||
pub disabled_reason: Option<String>,
|
||||
pub key_hint: Option<String>,
|
||||
pub blocked_reason: Option<String>,
|
||||
pub queue_guard: TicketQueueGuard,
|
||||
}
|
||||
|
||||
pub fn project_ticket_workspace_item(
|
||||
summary: &TicketSummary,
|
||||
relation_blockers: &[TicketRelationBlocker],
|
||||
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||
) -> TicketWorkspaceProjection {
|
||||
let visible_overlay = orchestration_overlay
|
||||
.filter(|overlay| {
|
||||
ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
||||
})
|
||||
.cloned();
|
||||
let mut projection = derive_ticket_workspace_projection(summary, relation_blockers);
|
||||
if let Some(overlay) = visible_overlay.as_ref() {
|
||||
apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay);
|
||||
}
|
||||
projection.visible_state =
|
||||
ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref());
|
||||
projection.visible_overlay = visible_overlay;
|
||||
projection.queue_guard = ticket_queue_guard(
|
||||
summary,
|
||||
relation_blockers,
|
||||
projection.visible_overlay.as_ref(),
|
||||
);
|
||||
projection
|
||||
}
|
||||
|
||||
pub fn ticket_queue_guard(
|
||||
summary: &TicketSummary,
|
||||
relation_blockers: &[TicketRelationBlocker],
|
||||
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||
) -> TicketQueueGuard {
|
||||
if orchestration_overlay.is_some() {
|
||||
return TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some(
|
||||
"orchestration overlay already shows progress; duplicate queue is suppressed"
|
||||
.to_string(),
|
||||
),
|
||||
blocked_reason: None,
|
||||
};
|
||||
}
|
||||
if summary.workflow_state != TicketWorkflowState::Ready {
|
||||
return TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some(format!(
|
||||
"Ticket state is {}; only ready Tickets can be queued for Orchestrator",
|
||||
summary.workflow_state.as_str()
|
||||
)),
|
||||
blocked_reason: None,
|
||||
};
|
||||
}
|
||||
let active_blockers = relation_blockers
|
||||
.iter()
|
||||
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||
.collect::<Vec<_>>();
|
||||
if !active_blockers.is_empty() {
|
||||
let blockers = format_workspace_relation_blockers(&active_blockers);
|
||||
return TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some(format!("waiting for {blockers}")),
|
||||
blocked_reason: Some(blockers),
|
||||
};
|
||||
}
|
||||
TicketQueueGuard {
|
||||
can_queue_for_orchestrator: true,
|
||||
reason: None,
|
||||
blocked_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_ticket_workspace_projection(
|
||||
summary: &TicketSummary,
|
||||
relation_blockers: &[TicketRelationBlocker],
|
||||
) -> TicketWorkspaceProjection {
|
||||
if !relation_blockers.is_empty() {
|
||||
let active_blockers = relation_blockers
|
||||
.iter()
|
||||
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||
.collect::<Vec<_>>();
|
||||
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
||||
let blockers_to_report = if active_blockers.is_empty() {
|
||||
relation_blockers.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
active_blockers
|
||||
};
|
||||
let blockers = format_workspace_relation_blockers(&blockers_to_report);
|
||||
let waiting_reason = format!("waiting for {blockers}");
|
||||
return TicketWorkspaceProjection {
|
||||
kind: workspace_row_kind_for_state(summary.workflow_state),
|
||||
priority: match summary.workflow_state {
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||
TicketWorkspaceActionPriority::ActiveWork
|
||||
}
|
||||
_ => TicketWorkspaceActionPriority::Background,
|
||||
},
|
||||
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some(format!(
|
||||
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
||||
)),
|
||||
key_hint: Some(format!("Gate: {waiting_reason}")),
|
||||
blocked_reason: Some(blockers),
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some(waiting_reason),
|
||||
blocked_reason: None,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let blockers = format_workspace_relation_blockers(
|
||||
&relation_blockers
|
||||
.iter()
|
||||
.collect::<Vec<&TicketRelationBlocker>>(),
|
||||
);
|
||||
return TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::Ticket,
|
||||
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: None,
|
||||
key_hint: Some(format!(
|
||||
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
||||
)),
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: true,
|
||||
reason: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
match summary.workflow_state {
|
||||
TicketWorkflowState::Ready => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::Ticket,
|
||||
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: None,
|
||||
key_hint: Some(
|
||||
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
||||
),
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: true,
|
||||
reason: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
TicketWorkflowState::Queued => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some("Ticket is already queued for Orchestrator routing".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
TicketWorkflowState::InProgress => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some("Ticket is already in progress".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
TicketWorkflowState::Done => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::Review,
|
||||
priority: TicketWorkspaceActionPriority::Background,
|
||||
next_action: Some(TicketWorkspaceNextAction::Close),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some(
|
||||
"state is done; close if a resolution is still missing.".to_string(),
|
||||
),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some("Ticket is done; close or review instead of queueing".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
TicketWorkflowState::Planning => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::Planning,
|
||||
priority: TicketWorkspaceActionPriority::Background,
|
||||
next_action: Some(TicketWorkspaceNextAction::Clarify),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some(
|
||||
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
||||
),
|
||||
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some("Ticket is still in planning".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
TicketWorkflowState::Closed => TicketWorkspaceProjection {
|
||||
kind: TicketWorkspaceRowKind::Review,
|
||||
priority: TicketWorkspaceActionPriority::Background,
|
||||
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||
visible_state: summary.workflow_state.as_str().to_string(),
|
||||
visible_overlay: None,
|
||||
disabled_reason: Some("Ticket is closed.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
queue_guard: TicketQueueGuard {
|
||||
can_queue_for_orchestrator: false,
|
||||
reason: Some("Ticket is closed".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning,
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||
TicketWorkspaceRowKind::ActiveWork
|
||||
}
|
||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review,
|
||||
TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_workspace_overlay_to_projection(
|
||||
projection: &mut TicketWorkspaceProjection,
|
||||
local: TicketWorkflowState,
|
||||
overlay: &TicketWorkspaceStateOverlay,
|
||||
) {
|
||||
projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator);
|
||||
let overlay_state = overlay.workflow_state.as_str();
|
||||
match overlay.workflow_state {
|
||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
||||
projection.kind = TicketWorkspaceRowKind::Review;
|
||||
projection.priority = TicketWorkspaceActionPriority::Background;
|
||||
projection.disabled_reason = Some(format!(
|
||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
||||
overlay.source,
|
||||
local.as_str()
|
||||
));
|
||||
projection.key_hint = Some(format!(
|
||||
"Merge pending: local: {} · {}: {overlay_state}",
|
||||
local.as_str(),
|
||||
overlay.source
|
||||
));
|
||||
}
|
||||
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
||||
projection.kind = TicketWorkspaceRowKind::ActiveWork;
|
||||
projection.priority = TicketWorkspaceActionPriority::ActiveWork;
|
||||
projection.disabled_reason = Some(format!(
|
||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
||||
overlay.source,
|
||||
local.as_str()
|
||||
));
|
||||
projection.key_hint = Some(format!(
|
||||
"Progress overlay: local: {} · {}: {overlay_state}",
|
||||
local.as_str(),
|
||||
overlay.source
|
||||
));
|
||||
}
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_workspace_state_display(
|
||||
local: TicketWorkflowState,
|
||||
overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||
) -> String {
|
||||
match overlay {
|
||||
Some(overlay) => format!(
|
||||
"{}→{}",
|
||||
compact_ticket_state_label(local),
|
||||
compact_ticket_state_label(overlay.workflow_state)
|
||||
),
|
||||
None => local.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_overlay_state_has_progressed(
|
||||
local: TicketWorkflowState,
|
||||
overlay: TicketWorkflowState,
|
||||
) -> bool {
|
||||
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
||||
}
|
||||
|
||||
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => 0,
|
||||
TicketWorkflowState::Ready => 1,
|
||||
TicketWorkflowState::Queued => 2,
|
||||
TicketWorkflowState::InProgress => 3,
|
||||
TicketWorkflowState::Done => 4,
|
||||
TicketWorkflowState::Closed => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => "plan",
|
||||
TicketWorkflowState::Ready => "ready",
|
||||
TicketWorkflowState::Queued => "q",
|
||||
TicketWorkflowState::InProgress => "prog",
|
||||
TicketWorkflowState::Done => "done",
|
||||
TicketWorkflowState::Closed => "cls",
|
||||
}
|
||||
}
|
||||
|
||||
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
||||
matches!(
|
||||
blocker.blocking_state,
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
||||
)
|
||||
}
|
||||
|
||||
fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
||||
let shown_blockers = blockers.iter().take(3).count();
|
||||
let mut formatted = blockers
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|blocker| {
|
||||
format!(
|
||||
"{} via {} (state: {})",
|
||||
blocker.blocking_ticket,
|
||||
blocker.reason_kind,
|
||||
blocker.blocking_state.as_str()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
||||
if remaining_blockers > 0 {
|
||||
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrchestrationPlanKind {
|
||||
|
|
@ -873,9 +1403,11 @@ impl TicketDoctorReport {
|
|||
|
||||
pub trait TicketBackend {
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>;
|
||||
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
|
||||
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
|
||||
fn create(&self, input: NewTicket) -> Result<TicketRef>;
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket>;
|
||||
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck>;
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
|
||||
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
||||
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
|
||||
|
|
@ -926,7 +1458,7 @@ pub enum TicketBackendOperation {
|
|||
from: String,
|
||||
},
|
||||
List {
|
||||
filter: TicketFilter,
|
||||
filter: TicketListQuery,
|
||||
},
|
||||
Show {
|
||||
id: TicketIdOrSlug,
|
||||
|
|
@ -934,6 +1466,13 @@ pub enum TicketBackendOperation {
|
|||
Create {
|
||||
input: NewTicket,
|
||||
},
|
||||
EditItem {
|
||||
id: TicketIdOrSlug,
|
||||
edit: TicketItemEdit,
|
||||
},
|
||||
DependencyCheck {
|
||||
id: TicketIdOrSlug,
|
||||
},
|
||||
AddEvent {
|
||||
id: TicketIdOrSlug,
|
||||
event: NewTicketEvent,
|
||||
|
|
@ -1002,6 +1541,7 @@ pub enum TicketBackendOperationResult {
|
|||
Tickets(Vec<TicketSummary>),
|
||||
Ticket(Ticket),
|
||||
TicketRef(TicketRef),
|
||||
DependencyCheck(TicketDependencyCheck),
|
||||
Relation(TicketRelation),
|
||||
Relations(Vec<TicketRelation>),
|
||||
RelationView(TicketRelationView),
|
||||
|
|
@ -1032,6 +1572,12 @@ where
|
|||
TicketBackendOperation::Create { input } => {
|
||||
TicketBackendOperationResult::TicketRef(backend.create(input)?)
|
||||
}
|
||||
TicketBackendOperation::EditItem { id, edit } => {
|
||||
TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?)
|
||||
}
|
||||
TicketBackendOperation::DependencyCheck { id } => {
|
||||
TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?)
|
||||
}
|
||||
TicketBackendOperation::AddEvent { id, event } => {
|
||||
backend.add_event(id, event)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
|
|
@ -1143,10 +1689,10 @@ impl LocalTicketBackend {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
|
||||
pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
|
||||
let mut output = TicketPartialList::default();
|
||||
let mut invalid_seen = BTreeSet::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
if !item.exists() {
|
||||
continue;
|
||||
|
|
@ -1155,10 +1701,7 @@ impl LocalTicketBackend {
|
|||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||
{
|
||||
Ok(meta) => {
|
||||
if filter
|
||||
.state
|
||||
.is_some_and(|state| meta.workflow_state != state)
|
||||
{
|
||||
if !filter.matches_state(meta.workflow_state) {
|
||||
continue;
|
||||
}
|
||||
output.tickets.push(ticket_summary_from_meta(meta));
|
||||
|
|
@ -1255,7 +1798,7 @@ impl LocalTicketBackend {
|
|||
}
|
||||
}
|
||||
|
||||
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> {
|
||||
fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
|
||||
let mut dirs = Vec::new();
|
||||
if !self.root.exists() {
|
||||
return Ok(dirs);
|
||||
|
|
@ -1275,10 +1818,10 @@ impl LocalTicketBackend {
|
|||
if !item.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(state) = filter.state {
|
||||
if !matches!(filter.state, TicketStateSelector::All) {
|
||||
let parsed = read_item_file(&item)?;
|
||||
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
|
||||
if meta.workflow_state != state {
|
||||
if !filter.matches_state(meta.workflow_state) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1495,7 +2038,7 @@ impl LocalTicketBackend {
|
|||
|
||||
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
|
||||
let mut relations = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
|
||||
}
|
||||
sort_ticket_relations(&mut relations);
|
||||
|
|
@ -1508,7 +2051,7 @@ impl LocalTicketBackend {
|
|||
invalid_seen: &mut BTreeSet<String>,
|
||||
) -> Result<Vec<TicketRelation>> {
|
||||
let mut relations = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
match self.read_ticket_relations_for_dir(&dir) {
|
||||
Ok(records) => relations.extend(records),
|
||||
Err(error) => {
|
||||
|
|
@ -1539,7 +2082,7 @@ impl LocalTicketBackend {
|
|||
|
||||
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||
let mut states = HashMap::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
||||
states.insert(meta.id, meta.workflow_state);
|
||||
|
|
@ -1553,7 +2096,7 @@ impl LocalTicketBackend {
|
|||
invalid_seen: &mut BTreeSet<String>,
|
||||
) -> Result<HashMap<String, TicketWorkflowState>> {
|
||||
let mut states = HashMap::new();
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let item = dir.join("item.md");
|
||||
match read_item_file(&item)
|
||||
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
|
||||
|
|
@ -1589,7 +2132,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||
self.default_intake_ready_state_change_body(from)
|
||||
}
|
||||
|
||||
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
|
||||
let mut tickets = Vec::new();
|
||||
for dir in self.iter_ticket_dirs(filter)? {
|
||||
let item = dir.join("item.md");
|
||||
|
|
@ -1706,6 +2249,79 @@ impl TicketBackend for LocalTicketBackend {
|
|||
})
|
||||
}
|
||||
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result<Ticket> {
|
||||
if edit.title.is_none() && edit.body.is_none() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketEditItem requires at least one of title or body".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(title) = edit.title.as_deref() {
|
||||
validate_required_event_value("title", title)?;
|
||||
}
|
||||
if let Some(author) = edit.author.as_deref() {
|
||||
validate_required_event_value("author", author)?;
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
|
||||
let mut updates = Vec::new();
|
||||
if let Some(title) = edit.title.as_deref() {
|
||||
updates.push(("title", title));
|
||||
}
|
||||
if !updates.is_empty() {
|
||||
content = replace_frontmatter_fields(&content, &updates).map_err(|message| {
|
||||
TicketError::Parse {
|
||||
path: item.clone(),
|
||||
message,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
if let Some(body) = edit.body.as_ref() {
|
||||
content = replace_item_body(&content, body.as_str()).map_err(|message| {
|
||||
TicketError::Parse {
|
||||
path: item.clone(),
|
||||
message,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
atomic_write(&item, content.as_bytes())?;
|
||||
|
||||
let author = edit.author.unwrap_or_else(default_author);
|
||||
let mut changes = Vec::new();
|
||||
if edit.title.is_some() {
|
||||
changes.push("title");
|
||||
}
|
||||
if edit.body.is_some() {
|
||||
changes.push("body");
|
||||
}
|
||||
let body = MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", ")));
|
||||
self.append_thread_event(
|
||||
&dir,
|
||||
"item_edit",
|
||||
self.generated_heading("Item updated", "項目更新"),
|
||||
&author,
|
||||
None,
|
||||
&[],
|
||||
&body,
|
||||
)?;
|
||||
self.ticket_from_dir(&dir)
|
||||
}
|
||||
|
||||
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> {
|
||||
let ticket = self.show(id)?;
|
||||
let summary = ticket_summary_from_meta(ticket.meta.clone());
|
||||
let projection = project_ticket_workspace_item(&summary, &ticket.relations.blockers, None);
|
||||
Ok(TicketDependencyCheck {
|
||||
ticket: summary,
|
||||
blockers: ticket.relations.blockers,
|
||||
queue_guard: projection.queue_guard,
|
||||
recommended_action: projection
|
||||
.next_action
|
||||
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||
})
|
||||
}
|
||||
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> {
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
|
|
@ -2088,7 +2704,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||
let dir = self.find_ticket_dir(&ticket)?;
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
} else {
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
}
|
||||
}
|
||||
|
|
@ -2117,7 +2733,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||
}
|
||||
}
|
||||
|
||||
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
|
||||
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
|
||||
let ticket_id = match ticket_id_from_dir(&dir) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
|
|
@ -3225,6 +3841,32 @@ fn replace_frontmatter_fields(
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn replace_item_body(content: &str, body: &str) -> std::result::Result<String, String> {
|
||||
let mut lines = content.lines();
|
||||
if lines.next() != Some("---") {
|
||||
return Err("item.md missing frontmatter opener".to_string());
|
||||
}
|
||||
let mut frontmatter = vec!["---".to_string()];
|
||||
let mut found_close = false;
|
||||
for line in lines.by_ref() {
|
||||
frontmatter.push(line.to_string());
|
||||
if line == "---" {
|
||||
found_close = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_close {
|
||||
return Err("item.md missing frontmatter closer".to_string());
|
||||
}
|
||||
let mut out = frontmatter.join("\n");
|
||||
out.push_str("\n");
|
||||
out.push_str(body);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
|
||||
let mut out = String::from("<!--");
|
||||
for (key, value) in attrs {
|
||||
|
|
@ -3859,6 +4501,108 @@ mod tests {
|
|||
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||
}
|
||||
|
||||
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||
TicketSummary {
|
||||
id: "000TEST".to_string(),
|
||||
slug: "000TEST".to_string(),
|
||||
title: "Test Ticket".to_string(),
|
||||
status: ExtensibleTicketStatus::Open,
|
||||
kind: "ticket".to_string(),
|
||||
priority: "P2".to_string(),
|
||||
labels: Vec::new(),
|
||||
readiness: None,
|
||||
workflow_state: state,
|
||||
workflow_state_explicit: true,
|
||||
queued_by: None,
|
||||
queued_at: None,
|
||||
updated_at: Some("2026-07-20T00:00:00Z".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker {
|
||||
TicketRelationBlocker {
|
||||
blocking_ticket: "000BLOCK".to_string(),
|
||||
reason_kind: "depends_on".to_string(),
|
||||
relation_kind: TicketRelationKind::DependsOn,
|
||||
note: None,
|
||||
blocking_state: state,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_projection_queues_ready_ticket_for_orchestrator() {
|
||||
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||
let projection = project_ticket_workspace_item(&summary, &[], None);
|
||||
|
||||
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||
assert_eq!(
|
||||
projection.priority,
|
||||
TicketWorkspaceActionPriority::ReadyForQueue
|
||||
);
|
||||
assert_eq!(
|
||||
projection.next_action,
|
||||
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||
);
|
||||
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||
assert!(projection.disabled_reason.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
|
||||
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
|
||||
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||
|
||||
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||
assert_eq!(
|
||||
projection.next_action,
|
||||
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||
);
|
||||
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||
assert!(projection.blocked_reason.is_some());
|
||||
assert!(projection.disabled_reason.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
|
||||
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
|
||||
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||
|
||||
assert_eq!(
|
||||
projection.next_action,
|
||||
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||
);
|
||||
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||
assert!(projection.blocked_reason.is_none());
|
||||
assert!(
|
||||
projection
|
||||
.key_hint
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Orchestrator will preserve order")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_projection_overlay_suppresses_duplicate_queue() {
|
||||
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||
let overlay = TicketWorkspaceStateOverlay {
|
||||
source: "orchestration".to_string(),
|
||||
workflow_state: TicketWorkflowState::InProgress,
|
||||
};
|
||||
let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay));
|
||||
|
||||
assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork);
|
||||
assert_eq!(
|
||||
projection.next_action,
|
||||
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||
);
|
||||
assert_eq!(projection.visible_state, "ready→prog");
|
||||
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||
assert!(projection.visible_overlay.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_rejects_legacy_intake_alias() {
|
||||
assert_eq!(
|
||||
|
|
@ -3975,6 +4719,57 @@ state: planning
|
|||
assert!(err.contains("invalid YAML frontmatter"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_query_defaults_to_active_and_supports_all_or_explicit_states() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||
let closed = backend.create(closed_input).unwrap();
|
||||
|
||||
let active = backend.list(TicketListQuery::default()).unwrap();
|
||||
let active_ids = active
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(active_ids.contains(&planning.id.as_str()));
|
||||
assert!(active_ids.contains(&ready.id.as_str()));
|
||||
assert!(!active_ids.contains(&closed.id.as_str()));
|
||||
|
||||
let all = backend.list(TicketListQuery::all()).unwrap();
|
||||
let all_ids = all
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(all_ids.contains(&planning.id.as_str()));
|
||||
assert!(all_ids.contains(&ready.id.as_str()));
|
||||
assert!(all_ids.contains(&closed.id.as_str()));
|
||||
|
||||
let ready_only = backend
|
||||
.list(TicketListQuery::state(TicketListState::Ready))
|
||||
.unwrap();
|
||||
assert_eq!(ready_only.len(), 1);
|
||||
assert_eq!(ready_only[0].id, ready.id);
|
||||
|
||||
let planning_or_closed = backend
|
||||
.list(TicketListQuery::states([
|
||||
TicketListState::Planning,
|
||||
TicketListState::Closed,
|
||||
]))
|
||||
.unwrap();
|
||||
let explicit_ids = planning_or_closed
|
||||
.iter()
|
||||
.map(|ticket| ticket.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(explicit_ids.contains(&planning.id.as_str()));
|
||||
assert!(explicit_ids.contains(&closed.id.as_str()));
|
||||
assert!(!explicit_ids.contains(&ready.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_writes_local_ticket_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
|
@ -4036,9 +4831,9 @@ state: planning
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(backend.list(TicketFilter::all()).is_err());
|
||||
assert!(backend.list(TicketListQuery::all()).is_err());
|
||||
|
||||
let partial = backend.list_partial(TicketFilter::all()).unwrap();
|
||||
let partial = backend.list_partial(TicketListQuery::all()).unwrap();
|
||||
assert_eq!(partial.tickets.len(), 1);
|
||||
assert_eq!(partial.tickets[0].id, valid.id);
|
||||
assert_eq!(partial.invalid_records.len(), 1);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ use crate::{
|
|||
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
||||
Result as TicketResult, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport,
|
||||
TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary,
|
||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
||||
TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||
TicketListState, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
||||
TicketReview, TicketReviewResult, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||
default_author,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
|
|
@ -33,20 +34,27 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
|||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
];
|
||||
|
||||
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] =
|
||||
["TicketList", "TicketShow", "TicketDoctor"];
|
||||
pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
];
|
||||
|
||||
pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||
"TicketRelationRecord",
|
||||
|
|
@ -58,35 +66,41 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
|||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 13] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 16] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
"TicketDoctor",
|
||||
];
|
||||
|
||||
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 5] = [
|
||||
pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
"TicketDoctor",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
|
|
@ -96,9 +110,12 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
|||
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
||||
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
||||
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
||||
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
|
||||
This updates the current item title/body and appends an audited item_edit thread event. Intended for \
|
||||
User/Companion authoring surfaces, not Orchestrator implementation control.";
|
||||
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
||||
lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
|
||||
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \
|
||||
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||
routing, closing, planning, or implementation decisions.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||
|
|
@ -111,6 +128,9 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be
|
|||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||
for `state`, and transitions state to `ready`.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||
and rejects unresolved blocking relations.";
|
||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||
|
|
@ -131,21 +151,26 @@ Ticket id and/or relation kind. This is read-only planning context; Orchestrator
|
|||
explicit state decisions.";
|
||||
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
||||
diagnostics through the typed backend without shelling out to external commands.";
|
||||
const DEPENDENCY_CHECK_DESCRIPTION: &str = "Return a structured Ticket dependency / queue readiness \
|
||||
check through the typed Ticket backend. This read-only guard does not queue or transition the Ticket.";
|
||||
|
||||
fn base_tool_description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketCreate" => CREATE_DESCRIPTION,
|
||||
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
|
||||
"TicketList" => LIST_DESCRIPTION,
|
||||
"TicketShow" => SHOW_DESCRIPTION,
|
||||
"TicketComment" => COMMENT_DESCRIPTION,
|
||||
"TicketReview" => REVIEW_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
"TicketClose" => CLOSE_DESCRIPTION,
|
||||
"TicketRelationRecord" => RELATION_RECORD_DESCRIPTION,
|
||||
"TicketRelationQuery" => RELATION_QUERY_DESCRIPTION,
|
||||
"TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
|
||||
"TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
|
||||
"TicketDependencyCheck" => DEPENDENCY_CHECK_DESCRIPTION,
|
||||
"TicketDoctor" => DOCTOR_DESCRIPTION,
|
||||
_ => "Ticket backend tool.",
|
||||
}
|
||||
|
|
@ -214,7 +239,7 @@ impl TicketBackend for TicketToolBackend {
|
|||
self.backend.default_intake_ready_state_change_body(from)
|
||||
}
|
||||
|
||||
fn list(&self, filter: crate::TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||
self.backend.list(filter)
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +251,14 @@ impl TicketBackend for TicketToolBackend {
|
|||
self.backend.create(input)
|
||||
}
|
||||
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: crate::TicketItemEdit) -> TicketResult<Ticket> {
|
||||
self.backend.edit_item(id, edit)
|
||||
}
|
||||
|
||||
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<crate::TicketDependencyCheck> {
|
||||
self.backend.dependency_check(id)
|
||||
}
|
||||
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
||||
self.backend.add_event(id, event)
|
||||
}
|
||||
|
|
@ -351,6 +384,21 @@ struct TicketCreateParams {
|
|||
queued_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketEditItemParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Optional replacement title.
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// Optional replacement Markdown body.
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
/// Optional thread author for the audited item_edit event.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketWorkflowStateParam {
|
||||
|
|
@ -373,11 +421,23 @@ impl TicketWorkflowStateParam {
|
|||
Self::Closed => TicketWorkflowState::Closed,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_list_state(self) -> TicketListState {
|
||||
match self {
|
||||
Self::Planning => TicketListState::Planning,
|
||||
Self::Ready => TicketListState::Ready,
|
||||
Self::Queued => TicketListState::Queued,
|
||||
Self::Inprogress => TicketListState::InProgress,
|
||||
Self::Done => TicketListState::Done,
|
||||
Self::Closed => TicketListState::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketListStateParam {
|
||||
Active,
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
|
|
@ -388,47 +448,62 @@ enum TicketListStateParam {
|
|||
}
|
||||
|
||||
impl TicketListStateParam {
|
||||
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
|
||||
fn as_list_state(self) -> Option<TicketListState> {
|
||||
match self {
|
||||
Self::Planning => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Planning),
|
||||
"planning",
|
||||
),
|
||||
Self::Ready => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Ready),
|
||||
"ready",
|
||||
),
|
||||
Self::Queued => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Queued),
|
||||
"queued",
|
||||
),
|
||||
Self::Inprogress => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::InProgress),
|
||||
"inprogress",
|
||||
),
|
||||
Self::Done => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Done),
|
||||
"done",
|
||||
),
|
||||
Self::Closed => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Closed),
|
||||
"closed",
|
||||
),
|
||||
Self::All => (crate::TicketFilter::all(), "all"),
|
||||
Self::Planning => Some(TicketListState::Planning),
|
||||
Self::Ready => Some(TicketListState::Ready),
|
||||
Self::Queued => Some(TicketListState::Queued),
|
||||
Self::Inprogress => Some(TicketListState::InProgress),
|
||||
Self::Done => Some(TicketListState::Done),
|
||||
Self::Closed => Some(TicketListState::Closed),
|
||||
Self::Active | Self::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketListParams {
|
||||
/// State filter. Defaults to all Tickets.
|
||||
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
|
||||
#[serde(default)]
|
||||
state: Option<TicketListStateParam>,
|
||||
/// Explicit workflow-state filter list. Cannot be combined with `state`.
|
||||
#[serde(default)]
|
||||
states: Option<Vec<TicketWorkflowStateParam>>,
|
||||
/// Maximum number of summaries to return. Defaults to 50, max 100.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl TicketListParams {
|
||||
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
|
||||
let query = if let Some(states) = self.states {
|
||||
if self.state.is_some() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList accepts either `state` or `states`, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if states.is_empty() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList `states` must include at least one workflow state".to_string(),
|
||||
));
|
||||
}
|
||||
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_list_state()))
|
||||
} else {
|
||||
match self.state.unwrap_or(TicketListStateParam::Active) {
|
||||
TicketListStateParam::Active => crate::TicketListQuery::active(),
|
||||
TicketListStateParam::All => crate::TicketListQuery::all(),
|
||||
state => crate::TicketListQuery::state(
|
||||
state
|
||||
.as_list_state()
|
||||
.expect("workflow state list param maps to TicketListState"),
|
||||
),
|
||||
}
|
||||
};
|
||||
let label = query.state_filter_label();
|
||||
Ok((query, label, self.limit))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketShowParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
|
|
@ -507,6 +582,15 @@ struct TicketIntakeReadyParams {
|
|||
state_change_body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Optional queued_by frontmatter value. Defaults to the backend/user default.
|
||||
#[serde(default)]
|
||||
queued_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketWorkflowStateParams {
|
||||
/// Ticket id.
|
||||
|
|
@ -532,6 +616,12 @@ struct TicketCloseParams {
|
|||
resolution: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketDependencyCheckParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketRelationKindParam {
|
||||
|
|
@ -723,6 +813,11 @@ struct TicketCreateTool {
|
|||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketEditItemTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketListTool {
|
||||
backend: TicketToolBackend,
|
||||
|
|
@ -748,6 +843,11 @@ struct TicketIntakeReadyTool {
|
|||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketQueueTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketWorkflowStateTool {
|
||||
backend: TicketToolBackend,
|
||||
|
|
@ -783,6 +883,11 @@ struct TicketDoctorTool {
|
|||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketDependencyCheckTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketCreateTool {
|
||||
async fn execute(
|
||||
|
|
@ -817,6 +922,35 @@ impl Tool for TicketCreateTool {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketEditItemTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketEditItemParams = parse_input("TicketEditItem", input_json)?;
|
||||
let edit = crate::TicketItemEdit {
|
||||
title: params.title,
|
||||
body: params.body.map(MarkdownText::new),
|
||||
author: params.author,
|
||||
};
|
||||
let ticket = self
|
||||
.backend
|
||||
.edit_item(TicketIdOrSlug::from(params.ticket), edit)
|
||||
.map_err(|error| backend_error("TicketEditItem", error))?;
|
||||
Ok(json_output(
|
||||
format!("Edited ticket {}", ticket.meta.id),
|
||||
ticket_json(
|
||||
&ticket,
|
||||
DEFAULT_EVENT_LIMIT,
|
||||
DEFAULT_ARTIFACT_LIMIT,
|
||||
16 * 1024,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketListTool {
|
||||
async fn execute(
|
||||
|
|
@ -825,9 +959,10 @@ impl Tool for TicketListTool {
|
|||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||
let state = params.state.unwrap_or(TicketListStateParam::All);
|
||||
let (filter, state_filter) = state.as_filter();
|
||||
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let (filter, state_filter, params_limit) = params
|
||||
.into_query()
|
||||
.map_err(|error| backend_error("TicketList", error))?;
|
||||
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let tickets = self
|
||||
.backend
|
||||
.list(filter)
|
||||
|
|
@ -987,6 +1122,25 @@ impl Tool for TicketIntakeReadyTool {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketQueueTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = params.queued_by.unwrap_or_else(default_author);
|
||||
self.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
Ok(json_output(
|
||||
format!("Queued ticket {} for Orchestrator", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "queued", "queued_by": queued_by, "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketWorkflowStateTool {
|
||||
async fn execute(
|
||||
|
|
@ -1216,6 +1370,33 @@ impl Tool for TicketDoctorTool {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketDependencyCheckTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketDependencyCheckParams = parse_input("TicketDependencyCheck", input_json)?;
|
||||
let check = self
|
||||
.backend
|
||||
.dependency_check(TicketIdOrSlug::Query(params.ticket.clone()))
|
||||
.map_err(|error| backend_error("TicketDependencyCheck", error))?;
|
||||
Ok(json_output(
|
||||
format!(
|
||||
"Ticket {} dependency check: {}",
|
||||
params.ticket,
|
||||
if check.queue_guard.can_queue_for_orchestrator {
|
||||
"queueable"
|
||||
} else {
|
||||
"not queueable"
|
||||
}
|
||||
),
|
||||
check,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||
|
|
@ -1481,15 +1662,20 @@ where
|
|||
fn input_schema(name: &str) -> Value {
|
||||
match name {
|
||||
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
|
||||
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
|
||||
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
||||
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
"TicketWorkflowState" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||
}
|
||||
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||
"TicketDependencyCheck" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams))
|
||||
}
|
||||
"TicketRelationRecord" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketRelationRecordParams))
|
||||
}
|
||||
|
|
@ -1519,11 +1705,13 @@ macro_rules! impl_from_backend {
|
|||
}
|
||||
|
||||
impl_from_backend!(TicketCreateTool);
|
||||
impl_from_backend!(TicketEditItemTool);
|
||||
impl_from_backend!(TicketListTool);
|
||||
impl_from_backend!(TicketShowTool);
|
||||
impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketReviewTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
impl_from_backend!(TicketCloseTool);
|
||||
impl_from_backend!(TicketRelationRecordTool);
|
||||
|
|
@ -1531,19 +1719,24 @@ impl_from_backend!(TicketRelationQueryTool);
|
|||
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
||||
impl_from_backend!(TicketDoctorTool);
|
||||
impl_from_backend!(TicketDependencyCheckTool);
|
||||
|
||||
/// Build all MVP Ticket tool definitions over the supplied backend.
|
||||
pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition> {
|
||||
let backend = backend.into();
|
||||
vec![
|
||||
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
|
||||
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
|
||||
tool_definition::<TicketListTool>("TicketList", backend.clone()),
|
||||
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
|
||||
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
|
||||
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||
tool_definition::<TicketCloseTool>("TicketClose", backend.clone()),
|
||||
tool_definition::<TicketDependencyCheckTool>("TicketDependencyCheck", backend.clone()),
|
||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend.clone()),
|
||||
tool_definition::<TicketRelationRecordTool>("TicketRelationRecord", backend.clone()),
|
||||
tool_definition::<TicketRelationQueryTool>("TicketRelationQuery", backend.clone()),
|
||||
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||
|
|
@ -1554,7 +1747,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
|||
"TicketOrchestrationPlanQuery",
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketDoctorTool>("TicketDoctor", backend),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1599,18 +1791,21 @@ mod tests {
|
|||
[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
"TicketDoctor"
|
||||
"TicketOrchestrationPlanQuery"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
TICKET_MUTATING_TOOL_NAMES,
|
||||
[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
|
|
@ -1825,6 +2020,16 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
let active = list
|
||||
.execute(&json!({}).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap();
|
||||
assert_eq!(active_json["state_filter"], "active");
|
||||
assert_eq!(active_json["count"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["returned"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["truncated"].as_bool(), Some(false));
|
||||
|
||||
let all = list
|
||||
.execute(&json!({ "state": "all" }).to_string(), Default::default())
|
||||
.await
|
||||
|
|
@ -1857,6 +2062,53 @@ mod tests {
|
|||
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
|
||||
let mut ready_input = NewTicket::new("Ready Ticket");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
let mut closed_input = NewTicket::new("Closed Ticket");
|
||||
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
|
||||
let closed = backend.create(closed_input).unwrap();
|
||||
|
||||
let listed = list
|
||||
.execute(
|
||||
&json!({ "states": ["planning", "closed"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap();
|
||||
assert_eq!(listed_json["state_filter"], "planning,closed");
|
||||
assert_eq!(listed_json["count"].as_u64(), Some(2));
|
||||
let listed_ids = listed_json["tickets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|ticket| ticket["id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(listed_ids.contains(&planning.id.as_str()));
|
||||
assert!(listed_ids.contains(&closed.id.as_str()));
|
||||
assert!(!listed_ids.contains(&ready.id.as_str()));
|
||||
|
||||
let mixed = list
|
||||
.execute(
|
||||
&json!({ "state": "active", "states": ["planning"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(mixed.is_err());
|
||||
|
||||
let empty = list
|
||||
.execute(&json!({ "states": [] }).to_string(), Default::default())
|
||||
.await;
|
||||
assert!(empty.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -2408,7 +2660,10 @@ mod tests {
|
|||
assert!(!id.contains("escape"));
|
||||
assert!(!temp.path().join("escape").exists());
|
||||
assert!(temp.path().join("tickets").join(id).is_dir());
|
||||
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -237,11 +237,9 @@ enabled = true
|
|||
enabled = false
|
||||
|
||||
[feature.ticket]
|
||||
enabled = false
|
||||
access = "lifecycle"
|
||||
|
||||
[feature.ticket_orchestration]
|
||||
enabled = false
|
||||
enabled = true
|
||||
authoring = true
|
||||
thread = true
|
||||
|
||||
[memory]
|
||||
extract_threshold = 50000
|
||||
|
|
@ -298,6 +296,9 @@ mod tests {
|
|||
assert!(profile.contains("slug = \"default\""));
|
||||
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
||||
assert!(profile.contains("scope = \"workspace_write\""));
|
||||
assert!(
|
||||
profile.contains("[feature.ticket]\nenabled = true\nauthoring = true\nthread = true")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ use ticket::config::{
|
|||
WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
|
||||
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
|
||||
TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction,
|
||||
TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item,
|
||||
};
|
||||
|
||||
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
||||
|
|
@ -266,11 +268,7 @@ pub(crate) struct TicketPanelEntry {
|
|||
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TicketStateOverlay {
|
||||
pub(crate) source: String,
|
||||
pub(crate) workflow_state: TicketWorkflowState,
|
||||
}
|
||||
pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TicketAssociatedIntakeEntry {
|
||||
|
|
@ -693,7 +691,7 @@ fn load_orchestration_ticket_overlay_states(
|
|||
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
|
||||
.with_record_language(overlay_config.ticket_record_language());
|
||||
let partial = backend
|
||||
.list_partial(TicketFilter::all())
|
||||
.list_partial(TicketListQuery::all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut states = BTreeMap::new();
|
||||
for summary in partial.tickets {
|
||||
|
|
@ -1092,7 +1090,7 @@ fn build_ticket_rows(
|
|||
registry: &PanelRegistrySnapshot,
|
||||
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
|
||||
) -> ticket::Result<TicketRowsBuild> {
|
||||
let partial = backend.list_partial(TicketFilter::all())?;
|
||||
let partial = backend.list_partial(TicketListQuery::all())?;
|
||||
let mut ticket_rows = Vec::new();
|
||||
let mut invalid_records = partial.invalid_records;
|
||||
for summary in partial.tickets {
|
||||
|
|
@ -1232,29 +1230,24 @@ fn ticket_row(
|
|||
related_workers.push(worker_name);
|
||||
}
|
||||
}
|
||||
let visible_overlay = orchestration_overlay
|
||||
.filter(|overlay| {
|
||||
overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
||||
})
|
||||
.cloned();
|
||||
let mut derived = derive_ticket_state(&summary, relation_blockers);
|
||||
if let Some(overlay) = visible_overlay.as_ref() {
|
||||
apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay);
|
||||
}
|
||||
let projection =
|
||||
project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay);
|
||||
let latest_event = events.last();
|
||||
let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref());
|
||||
let kind = panel_row_kind_from_workspace(projection.kind);
|
||||
let priority = action_priority_from_workspace(projection.priority);
|
||||
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
||||
let entry = TicketPanelEntry {
|
||||
id: summary.id.clone(),
|
||||
title: summary.title.clone(),
|
||||
priority: summary.priority.clone(),
|
||||
workflow_state: summary.workflow_state,
|
||||
workflow_state_explicit: summary.workflow_state_explicit,
|
||||
orchestration_overlay: visible_overlay,
|
||||
next_action: derived.action,
|
||||
orchestration_overlay: projection.visible_overlay.clone(),
|
||||
next_action,
|
||||
updated_at: summary.updated_at.clone(),
|
||||
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
||||
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
||||
blocked_reason: derived.blocked_reason.clone(),
|
||||
blocked_reason: projection.blocked_reason.clone(),
|
||||
related_workers: related_workers.clone(),
|
||||
local_claim,
|
||||
intake_workers,
|
||||
|
|
@ -1262,251 +1255,42 @@ fn ticket_row(
|
|||
let subtitle = ticket_subtitle(&entry);
|
||||
PanelRow {
|
||||
key: PanelRowKey::Ticket(summary.id),
|
||||
kind: derived.kind,
|
||||
kind,
|
||||
title: summary.title,
|
||||
subtitle,
|
||||
status: state_display,
|
||||
priority: derived.priority,
|
||||
next_action: derived.action,
|
||||
status: projection.visible_state,
|
||||
priority,
|
||||
next_action,
|
||||
ticket: Some(entry),
|
||||
related_workers,
|
||||
disabled_reason: derived.disabled_reason,
|
||||
key_hint: derived.key_hint,
|
||||
disabled_reason: projection.disabled_reason,
|
||||
key_hint: projection.key_hint,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DerivedTicketState {
|
||||
kind: PanelRowKind,
|
||||
priority: ActionPriority,
|
||||
action: Option<NextUserAction>,
|
||||
disabled_reason: Option<String>,
|
||||
key_hint: Option<String>,
|
||||
blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => 0,
|
||||
TicketWorkflowState::Ready => 1,
|
||||
TicketWorkflowState::Queued => 2,
|
||||
TicketWorkflowState::InProgress => 3,
|
||||
TicketWorkflowState::Done => 4,
|
||||
TicketWorkflowState::Closed => 5,
|
||||
fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind {
|
||||
match kind {
|
||||
TicketWorkspaceRowKind::Planning => PanelRowKind::Planning,
|
||||
TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket,
|
||||
TicketWorkspaceRowKind::Review => PanelRowKind::Review,
|
||||
TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool {
|
||||
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
||||
}
|
||||
|
||||
fn ticket_state_display(
|
||||
local: TicketWorkflowState,
|
||||
overlay: Option<&TicketStateOverlay>,
|
||||
) -> String {
|
||||
match overlay {
|
||||
Some(overlay) => format!(
|
||||
"{}→{}",
|
||||
compact_ticket_state_label(local),
|
||||
compact_ticket_state_label(overlay.workflow_state)
|
||||
),
|
||||
None => local.as_str().to_string(),
|
||||
fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority {
|
||||
match priority {
|
||||
TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue,
|
||||
TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork,
|
||||
TicketWorkspaceActionPriority::Background => ActionPriority::Background,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => "plan",
|
||||
TicketWorkflowState::Ready => "ready",
|
||||
TicketWorkflowState::Queued => "q",
|
||||
TicketWorkflowState::InProgress => "prog",
|
||||
TicketWorkflowState::Done => "done",
|
||||
TicketWorkflowState::Closed => "cls",
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_orchestration_overlay_to_derived(
|
||||
derived: &mut DerivedTicketState,
|
||||
local: TicketWorkflowState,
|
||||
overlay: &TicketStateOverlay,
|
||||
) {
|
||||
derived.action = Some(NextUserAction::Wait);
|
||||
let overlay_state = overlay.workflow_state.as_str();
|
||||
match overlay.workflow_state {
|
||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
||||
derived.kind = PanelRowKind::Review;
|
||||
derived.priority = ActionPriority::Background;
|
||||
derived.disabled_reason = Some(format!(
|
||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
||||
overlay.source,
|
||||
local.as_str()
|
||||
));
|
||||
derived.key_hint = Some(format!(
|
||||
"Merge pending: local: {} · {}: {overlay_state}",
|
||||
local.as_str(),
|
||||
overlay.source
|
||||
));
|
||||
}
|
||||
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
||||
derived.kind = PanelRowKind::ActiveWork;
|
||||
derived.priority = ActionPriority::ActiveWork;
|
||||
derived.disabled_reason = Some(format!(
|
||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
||||
overlay.source,
|
||||
local.as_str()
|
||||
));
|
||||
derived.key_hint = Some(format!(
|
||||
"Progress overlay: local: {} · {}: {overlay_state}",
|
||||
local.as_str(),
|
||||
overlay.source
|
||||
));
|
||||
}
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
||||
let shown_blockers = blockers.iter().take(3).count();
|
||||
let mut formatted = blockers
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|blocker| {
|
||||
format!(
|
||||
"{} via {} (state: {})",
|
||||
blocker.blocking_ticket,
|
||||
blocker.reason_kind,
|
||||
blocker.blocking_state.as_str()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
||||
if remaining_blockers > 0 {
|
||||
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
||||
matches!(
|
||||
blocker.blocking_state,
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
||||
)
|
||||
}
|
||||
|
||||
fn derive_ticket_state(
|
||||
summary: &TicketSummary,
|
||||
relation_blockers: &[TicketRelationBlocker],
|
||||
) -> DerivedTicketState {
|
||||
if !relation_blockers.is_empty() {
|
||||
let active_blockers = relation_blockers
|
||||
.iter()
|
||||
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||
.collect::<Vec<_>>();
|
||||
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
||||
let blockers_to_report = if active_blockers.is_empty() {
|
||||
relation_blockers.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
active_blockers
|
||||
};
|
||||
let blockers = format_relation_blockers(&blockers_to_report);
|
||||
let waiting_reason = format!("waiting for {blockers}");
|
||||
return DerivedTicketState {
|
||||
kind: match summary.workflow_state {
|
||||
TicketWorkflowState::Planning => PanelRowKind::Planning,
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||
PanelRowKind::ActiveWork
|
||||
}
|
||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
|
||||
TicketWorkflowState::Ready => PanelRowKind::Ticket,
|
||||
},
|
||||
priority: match summary.workflow_state {
|
||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||
ActionPriority::ActiveWork
|
||||
}
|
||||
_ => ActionPriority::Background,
|
||||
},
|
||||
action: Some(NextUserAction::Wait),
|
||||
disabled_reason: Some(format!(
|
||||
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
||||
)),
|
||||
key_hint: Some(format!("Gate: {waiting_reason}")),
|
||||
blocked_reason: Some(blockers),
|
||||
};
|
||||
}
|
||||
|
||||
let blockers = format_relation_blockers(
|
||||
&relation_blockers
|
||||
.iter()
|
||||
.collect::<Vec<&TicketRelationBlocker>>(),
|
||||
);
|
||||
return DerivedTicketState {
|
||||
kind: PanelRowKind::Ticket,
|
||||
priority: ActionPriority::ReadyForQueue,
|
||||
action: Some(NextUserAction::Queue),
|
||||
disabled_reason: None,
|
||||
key_hint: Some(format!(
|
||||
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
||||
)),
|
||||
blocked_reason: None,
|
||||
};
|
||||
}
|
||||
|
||||
match summary.workflow_state {
|
||||
TicketWorkflowState::Ready => DerivedTicketState {
|
||||
kind: PanelRowKind::Ticket,
|
||||
priority: ActionPriority::ReadyForQueue,
|
||||
action: Some(NextUserAction::Queue),
|
||||
disabled_reason: None,
|
||||
key_hint: Some(
|
||||
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
||||
),
|
||||
blocked_reason: None,
|
||||
},
|
||||
TicketWorkflowState::Queued => DerivedTicketState {
|
||||
kind: PanelRowKind::ActiveWork,
|
||||
priority: ActionPriority::ActiveWork,
|
||||
action: Some(NextUserAction::Wait),
|
||||
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
TicketWorkflowState::InProgress => DerivedTicketState {
|
||||
kind: PanelRowKind::ActiveWork,
|
||||
priority: ActionPriority::ActiveWork,
|
||||
action: Some(NextUserAction::Wait),
|
||||
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
TicketWorkflowState::Done => DerivedTicketState {
|
||||
kind: PanelRowKind::Review,
|
||||
priority: ActionPriority::Background,
|
||||
action: Some(NextUserAction::Close),
|
||||
disabled_reason: Some(
|
||||
"state is done; close if a resolution is still missing.".to_string(),
|
||||
),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
TicketWorkflowState::Planning => DerivedTicketState {
|
||||
kind: PanelRowKind::Planning,
|
||||
priority: ActionPriority::Background,
|
||||
action: Some(NextUserAction::Clarify),
|
||||
disabled_reason: Some(
|
||||
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
||||
),
|
||||
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
||||
blocked_reason: None,
|
||||
},
|
||||
TicketWorkflowState::Closed => DerivedTicketState {
|
||||
kind: PanelRowKind::Review,
|
||||
priority: ActionPriority::Background,
|
||||
action: Some(NextUserAction::Wait),
|
||||
disabled_reason: Some("Ticket is closed.".to_string()),
|
||||
key_hint: None,
|
||||
blocked_reason: None,
|
||||
},
|
||||
fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction {
|
||||
match action {
|
||||
TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify,
|
||||
TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue,
|
||||
TicketWorkspaceNextAction::Close => NextUserAction::Close,
|
||||
TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1651,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod(
|
|||
TicketLocalClaimStatus::Stale
|
||||
}
|
||||
|
||||
fn ticket_state_display(
|
||||
local: TicketWorkflowState,
|
||||
overlay: Option<&TicketStateOverlay>,
|
||||
) -> String {
|
||||
match overlay {
|
||||
Some(overlay) => format!(
|
||||
"{}→{}",
|
||||
compact_ticket_state_label(local),
|
||||
compact_ticket_state_label(overlay.workflow_state)
|
||||
),
|
||||
None => local.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||
match state {
|
||||
TicketWorkflowState::Planning => "plan",
|
||||
TicketWorkflowState::Ready => "ready",
|
||||
TicketWorkflowState::Queued => "q",
|
||||
TicketWorkflowState::InProgress => "prog",
|
||||
TicketWorkflowState::Done => "done",
|
||||
TicketWorkflowState::Closed => "cls",
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||
let mut parts = vec![format!(
|
||||
"{} · {}",
|
||||
|
|
@ -2522,7 +2331,7 @@ mod tests {
|
|||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
});
|
||||
let ticket_id = backend
|
||||
.list(TicketFilter::all())
|
||||
.list(TicketListQuery::all())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|ticket| ticket.title == "Ticket With Intake")
|
||||
|
|
@ -2619,7 +2428,7 @@ mod tests {
|
|||
write_ticket_config(temp.path());
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
create_ticket(&backend, "Claimed Planning", |_| {});
|
||||
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
||||
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
|
||||
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
||||
store
|
||||
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")
|
||||
|
|
|
|||
|
|
@ -543,7 +543,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||
struct RuntimeWorkerExecution {
|
||||
handle: WorkerHandle,
|
||||
busy: Arc<AtomicBool>,
|
||||
working_directory: Option<WorkingDirectoryBinding>,
|
||||
}
|
||||
|
||||
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
|
||||
|
|
@ -621,7 +620,12 @@ where
|
|||
{
|
||||
let (tx, rx) = mpsc::sync_channel(1);
|
||||
self.spawn_on_adapter_runtime(async move {
|
||||
let _ = tx.send(task.await);
|
||||
let handle = tokio::spawn(task);
|
||||
let result = match handle.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => Err(format!("worker adapter task failed: {err}")),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
})?;
|
||||
Self::wait_for_runtime_task(rx)
|
||||
}
|
||||
|
|
@ -721,14 +725,7 @@ where
|
|||
));
|
||||
}
|
||||
};
|
||||
workers.insert(
|
||||
worker_ref.clone(),
|
||||
RuntimeWorkerExecution {
|
||||
handle,
|
||||
busy,
|
||||
working_directory: working_directory.clone(),
|
||||
},
|
||||
);
|
||||
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
|
||||
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
|
||||
|
|
@ -816,6 +813,7 @@ where
|
|||
}
|
||||
|
||||
let mut request = request;
|
||||
let mut rollback_working_directory = None;
|
||||
let working_directory = match (
|
||||
request.request.working_directory_request.as_ref(),
|
||||
request.request.working_directory.as_ref(),
|
||||
|
|
@ -836,6 +834,7 @@ where
|
|||
match materializer.materialize(&request.worker_ref, working_directory_request) {
|
||||
Ok(binding) => {
|
||||
request.working_directory = Some(binding.clone());
|
||||
rollback_working_directory = Some(binding.clone());
|
||||
Some(binding)
|
||||
}
|
||||
Err(error) => {
|
||||
|
|
@ -887,9 +886,9 @@ where
|
|||
Err(message) => {
|
||||
if let (Some(materializer), Some(binding)) = (
|
||||
self.working_directory_materializer.as_ref(),
|
||||
working_directory.as_ref(),
|
||||
rollback_working_directory.as_ref(),
|
||||
) {
|
||||
let _ = materializer.cleanup(binding);
|
||||
let _ = materializer.cleanup_working_directory(&binding.working_directory.id);
|
||||
}
|
||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Spawn,
|
||||
|
|
@ -1089,19 +1088,12 @@ where
|
|||
"execution handle does not reference a live Worker",
|
||||
);
|
||||
};
|
||||
let result = self.send_method(
|
||||
self.send_method(
|
||||
WorkerExecutionOperation::Stop,
|
||||
execution.handle,
|
||||
Method::Shutdown,
|
||||
WorkerExecutionRunState::Stopped,
|
||||
);
|
||||
if let (Some(materializer), Some(binding)) = (
|
||||
self.working_directory_materializer.as_ref(),
|
||||
execution.working_directory.as_ref(),
|
||||
) {
|
||||
let _ = materializer.cleanup(binding);
|
||||
}
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
|
|
@ -1162,7 +1154,8 @@ mod tests {
|
|||
use crate::Runtime as EmbeddedRuntime;
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
|
||||
RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||
RepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository,
|
||||
WorkingDirectoryRequest,
|
||||
};
|
||||
use crate::execution::WorkerExecutionContext;
|
||||
use crate::management::RuntimeOptions;
|
||||
|
|
@ -1404,6 +1397,42 @@ mod tests {
|
|||
assert!(status.success(), "git {:?} failed", args);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailingFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl RuntimeWorkerFactory for FailingFactory {
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
_request: WorkerExecutionSpawnRequest,
|
||||
) -> Result<WorkerHandle, String> {
|
||||
Err("spawn failed".to_string())
|
||||
}
|
||||
|
||||
async fn restore_controller(
|
||||
&self,
|
||||
_request: WorkerExecutionRestoreRequest,
|
||||
) -> Result<WorkerHandle, String> {
|
||||
Err("restore failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_runtime_reports_task_panic() {
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||
|
||||
let error = backend
|
||||
.run_on_adapter_runtime(async {
|
||||
panic!("adapter boom");
|
||||
#[allow(unreachable_code)]
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("worker adapter task failed"));
|
||||
assert!(error.contains("adapter boom") || error.contains("panicked"));
|
||||
}
|
||||
|
||||
fn create_clean_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
git(dir.path(), &["init"]);
|
||||
|
|
@ -1432,6 +1461,17 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn materialized_worktree_root(
|
||||
runtime_base: &std::path::Path,
|
||||
working_directory_id: &str,
|
||||
) -> PathBuf {
|
||||
runtime_base
|
||||
.join("working-directories")
|
||||
.join(working_directory_id)
|
||||
.join("root")
|
||||
.join("repo-main")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_worker_name_is_runtime_local() {
|
||||
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1));
|
||||
|
|
@ -1617,4 +1657,117 @@ mod tests {
|
|||
assert!(!cwd.starts_with(repo.path()));
|
||||
assert!(cwd.join("README.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_and_deleting_worker_preserves_bound_working_directory() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let store = tempfile::tempdir().unwrap();
|
||||
let factory = MockFactory {
|
||||
client,
|
||||
runtime_base: runtime_base.path().to_path_buf(),
|
||||
cwd: repo.path().to_path_buf(),
|
||||
store_dir: store.path().join("sessions"),
|
||||
worker_metadata_dir: store.path().join("workers"),
|
||||
observed_cwds: Arc::new(Mutex::new(Vec::new())),
|
||||
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let backend = WorkerRuntimeExecutionBackend::new(factory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = create_request("chat");
|
||||
request.working_directory_request = Some(working_directory_request(repo.path()));
|
||||
let detail = runtime.create_worker(request).unwrap();
|
||||
let workdir_id = detail
|
||||
.execution
|
||||
.working_directory
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.summary
|
||||
.working_directory_id
|
||||
.clone();
|
||||
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id);
|
||||
assert!(worktree_root.join("README.md").exists());
|
||||
|
||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||
runtime.delete_worker(&detail.worker_ref).unwrap();
|
||||
|
||||
assert!(worktree_root.join("README.md").exists());
|
||||
let status = runtime.working_directory(&workdir_id).unwrap();
|
||||
assert_eq!(
|
||||
status.summary.status,
|
||||
crate::catalog::WorkingDirectoryStatusKind::Active
|
||||
);
|
||||
assert_eq!(status.summary.cleanliness.as_deref(), Some("clean"));
|
||||
assert_eq!(status.summary.primary_worker_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_failure_with_existing_working_directory_preserves_workdir() {
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let status = runtime
|
||||
.create_working_directory(working_directory_request(repo.path()))
|
||||
.unwrap();
|
||||
let workdir_id = status.summary.working_directory_id.clone();
|
||||
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id);
|
||||
assert!(worktree_root.join("README.md").exists());
|
||||
let mut request = create_request("chat");
|
||||
request.working_directory = Some(WorkingDirectoryClaim {
|
||||
working_directory_id: workdir_id.clone(),
|
||||
relative_cwd: None,
|
||||
});
|
||||
|
||||
let error = runtime.create_worker(request).unwrap_err();
|
||||
|
||||
assert!(format!("{error:?}").contains("spawn failed"));
|
||||
assert!(worktree_root.join("README.md").exists());
|
||||
let status = runtime.working_directory(&workdir_id).unwrap();
|
||||
assert_eq!(
|
||||
status.summary.status,
|
||||
crate::catalog::WorkingDirectoryStatusKind::Active
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_failure_with_new_materialization_rolls_back_workdir_record() {
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
|
||||
runtime_base.path(),
|
||||
));
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = create_request("chat");
|
||||
request.working_directory_request = Some(working_directory_request(repo.path()));
|
||||
|
||||
let error = runtime.create_worker(request).unwrap_err();
|
||||
|
||||
assert!(format!("{error:?}").contains("spawn failed"));
|
||||
let working_directories_root = runtime_base.path().join("working-directories");
|
||||
let remaining_entries = fs::read_dir(working_directories_root)
|
||||
.map(|entries| entries.count())
|
||||
.unwrap_or(0);
|
||||
assert_eq!(remaining_entries, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,13 @@ impl LocalGitWorktreeMaterializer {
|
|||
)
|
||||
})?;
|
||||
|
||||
let selector = request
|
||||
.repository
|
||||
.selector
|
||||
.as_deref()
|
||||
.unwrap_or("HEAD")
|
||||
.to_string();
|
||||
if selector == "HEAD" {
|
||||
let status = git_stdout(&source_root, ["status", "--porcelain"])?;
|
||||
if !status.trim().is_empty() {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
|
|
@ -327,13 +334,7 @@ impl LocalGitWorktreeMaterializer {
|
|||
"working directory materialization rejects dirty source repository state",
|
||||
));
|
||||
}
|
||||
|
||||
let selector = request
|
||||
.repository
|
||||
.selector
|
||||
.as_deref()
|
||||
.unwrap_or("HEAD")
|
||||
.to_string();
|
||||
}
|
||||
let commit_spec = format!("{selector}^{{commit}}");
|
||||
let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])?
|
||||
.trim()
|
||||
|
|
@ -851,6 +852,30 @@ mod tests {
|
|||
assert!(error.message.contains("dirty source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_selector_allows_dirty_source_materialization() {
|
||||
let repo = create_clean_repo();
|
||||
git(repo.path(), &["branch", "pinned"]);
|
||||
fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let mut request = request(repo.path());
|
||||
request.repository.selector = Some(RepositorySelector::from("pinned"));
|
||||
|
||||
let binding = materializer.materialize(&worker_ref(1), &request).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
binding
|
||||
.working_directory
|
||||
.evidence
|
||||
.requested_selector
|
||||
.as_deref(),
|
||||
Some("pinned")
|
||||
);
|
||||
assert!(binding.root.join("README.md").exists());
|
||||
assert!(!binding.root.join("dirty.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ use tools::ScopedFs;
|
|||
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::fs_view::{ReadRequirement, slice_lines};
|
||||
use crate::session_reference::{
|
||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionReferenceView, ToolPart,
|
||||
};
|
||||
|
||||
/// Aggregated output of a compact worker run.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
|
|
@ -143,6 +146,7 @@ this to verify details before writing the summary.";
|
|||
|
||||
struct SessionLogToolState {
|
||||
items: Arc<Vec<Item>>,
|
||||
view: SessionReferenceView,
|
||||
}
|
||||
|
||||
struct SearchSessionLogTool {
|
||||
|
|
@ -165,27 +169,44 @@ impl Tool for SearchSessionLogTool {
|
|||
"search_session_log query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let offset = params.offset.unwrap_or(0).min(self.state.items.len());
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params
|
||||
.limit
|
||||
.unwrap_or(20)
|
||||
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
||||
let mut hits = Vec::new();
|
||||
for (idx, item) in self.state.items.iter().enumerate().skip(offset) {
|
||||
let haystack = session_item_search_text(item).to_lowercase();
|
||||
if haystack.contains(&query) {
|
||||
hits.push(format_session_item(
|
||||
idx,
|
||||
item,
|
||||
SessionReadMode::Compact,
|
||||
600,
|
||||
));
|
||||
if hits.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut content = hits.join("\n\n");
|
||||
let hits = self.state.view.search(&SearchOptions {
|
||||
query: params.query.clone(),
|
||||
kind: None,
|
||||
tool_part: None,
|
||||
tool_name: None,
|
||||
limit: Some(limit),
|
||||
min_entry_index: Some(offset as u64),
|
||||
});
|
||||
let blocks = hits
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let part = hit
|
||||
.tool_part
|
||||
.map(|part| format!(" {part:?}"))
|
||||
.unwrap_or_default();
|
||||
let tool = hit
|
||||
.tool_name
|
||||
.as_ref()
|
||||
.map(|name| format!(" {name}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"[{} {}{}{} {:?}] {}\n{}",
|
||||
hit.id,
|
||||
hit.kind.as_str(),
|
||||
part,
|
||||
tool,
|
||||
hit.entry_range,
|
||||
hit.label,
|
||||
hit.summary
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut content = blocks.join("\n\n");
|
||||
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||
let summary = if hits.is_empty() {
|
||||
format!("No session log hits for {query:?} from item offset {offset}.")
|
||||
|
|
@ -222,17 +243,36 @@ impl Tool for ReadSessionItemsTool {
|
|||
let offset = params.offset.min(self.state.items.len());
|
||||
let limit = params.limit.clamp(1, SESSION_READ_MAX_ITEMS);
|
||||
let end = offset.saturating_add(limit).min(self.state.items.len());
|
||||
let mut blocks = Vec::new();
|
||||
for idx in offset..end {
|
||||
blocks.push(format_session_item(
|
||||
idx,
|
||||
&self.state.items[idx],
|
||||
mode,
|
||||
4_000,
|
||||
));
|
||||
let detail = match mode {
|
||||
SessionReadMode::Compact => ReadDetail::Compact,
|
||||
SessionReadMode::Full => ReadDetail::Full,
|
||||
};
|
||||
let read = if offset >= end {
|
||||
crate::session_reference::ReadResult {
|
||||
entries: Vec::new(),
|
||||
truncated: false,
|
||||
}
|
||||
let mut content = blocks.join("\n\n");
|
||||
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||
} else {
|
||||
self.state.view.read(
|
||||
ReadSelector::EntryRange([offset as u64, end.saturating_sub(1) as u64]),
|
||||
ReadOptions {
|
||||
include_tools: true,
|
||||
tool_part: ToolPart::Both,
|
||||
detail,
|
||||
max_items: limit,
|
||||
max_bytes: 48 * 1024,
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut content = read
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let token_truncated =
|
||||
truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||
let truncated = read.truncated || token_truncated;
|
||||
let summary = if truncated {
|
||||
format!(
|
||||
"Read session items {offset}..{end} in {mode:?} mode; output truncated. Narrow the range."
|
||||
|
|
@ -265,93 +305,6 @@ impl SessionReadMode {
|
|||
}
|
||||
}
|
||||
|
||||
fn session_item_search_text(item: &Item) -> String {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => format!(
|
||||
"{:?} {}",
|
||||
role,
|
||||
content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
),
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => format!("tool_call {name} {arguments}"),
|
||||
Item::ToolResult {
|
||||
summary, content, ..
|
||||
} => format!(
|
||||
"tool_result {summary} {}",
|
||||
content.as_deref().unwrap_or_default()
|
||||
),
|
||||
Item::Reasoning { text, summary, .. } => format!("reasoning {text} {}", summary.join(" ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_session_item(idx: usize, item: &Item, mode: SessionReadMode, max_chars: usize) -> String {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let text = content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!(
|
||||
"[{idx} Message {:?}] {}",
|
||||
role,
|
||||
truncate_chars(&text, max_chars)
|
||||
)
|
||||
}
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => match mode {
|
||||
SessionReadMode::Compact => format!("[{idx} ToolCall] {name} (arguments omitted)"),
|
||||
SessionReadMode::Full => format!(
|
||||
"[{idx} ToolCall] {name}\narguments: {}",
|
||||
truncate_chars(arguments, max_chars)
|
||||
),
|
||||
},
|
||||
Item::ToolResult {
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => match mode {
|
||||
SessionReadMode::Compact => format!(
|
||||
"[{idx} ToolResult{}] {} (content omitted)",
|
||||
if *is_error { " error" } else { "" },
|
||||
truncate_chars(summary, 800)
|
||||
),
|
||||
SessionReadMode::Full => format!(
|
||||
"[{idx} ToolResult{}] {}\ncontent: {}",
|
||||
if *is_error { " error" } else { "" },
|
||||
truncate_chars(summary, 800),
|
||||
truncate_chars(content.as_deref().unwrap_or(""), max_chars)
|
||||
),
|
||||
},
|
||||
Item::Reasoning { summary, .. } => match mode {
|
||||
SessionReadMode::Compact => format!(
|
||||
"[{idx} Reasoning] {} (body omitted)",
|
||||
truncate_chars(&summary.join(" "), 800)
|
||||
),
|
||||
SessionReadMode::Full => format!(
|
||||
"[{idx} Reasoning] {} (body omitted)",
|
||||
truncate_chars(&summary.join(" "), 800)
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out = text.chars().take(max_chars).collect::<String>();
|
||||
out.push_str("… [truncated]");
|
||||
out
|
||||
}
|
||||
|
||||
fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
|
||||
let max_bytes = max_tokens.saturating_mul(4) as usize;
|
||||
if text.len() <= max_bytes {
|
||||
|
|
@ -531,7 +484,8 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
|
|||
}
|
||||
|
||||
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||
let state = Arc::new(SessionLogToolState { items });
|
||||
let view = SessionReferenceView::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SearchSessionParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
|
|
@ -546,7 +500,8 @@ pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
|||
}
|
||||
|
||||
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
|
||||
let state = Arc::new(SessionLogToolState { items });
|
||||
let view = SessionReferenceView::new("compact-target", (*items).clone());
|
||||
let state = Arc::new(SessionLogToolState { items, view });
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadSessionParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
|
|
@ -839,8 +794,9 @@ mod tests {
|
|||
"very large raw trace body with secret detail",
|
||||
),
|
||||
]);
|
||||
let view = SessionReferenceView::new("test", (*items).clone());
|
||||
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
|
||||
state: Arc::new(SessionLogToolState { items }),
|
||||
state: Arc::new(SessionLogToolState { items, view }),
|
||||
});
|
||||
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
||||
|
|
@ -858,8 +814,9 @@ mod tests {
|
|||
"read trace",
|
||||
"raw trace detail",
|
||||
)]);
|
||||
let view = SessionReferenceView::new("test", (*items).clone());
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
|
||||
state: Arc::new(SessionLogToolState { items }),
|
||||
state: Arc::new(SessionLogToolState { items, view }),
|
||||
});
|
||||
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ use std::sync::atomic::Ordering;
|
|||
|
||||
use llm_engine::EngineError;
|
||||
use llm_engine::llm_client::client::LlmClient;
|
||||
use manifest::TicketFeatureAccessConfig;
|
||||
use session_store::WorkerMetadataStore;
|
||||
use session_store::{LogEntry, Store};
|
||||
use ticket::LocalTicketBackend;
|
||||
|
|
@ -547,9 +546,7 @@ fn install_ticket_event_companion_notify_hook<C, St>(
|
|||
}
|
||||
|
||||
let ticket_feature = &worker.manifest().feature.ticket;
|
||||
if !ticket_feature.enabled
|
||||
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
|
||||
{
|
||||
if !ticket_feature.enabled || !ticket_feature.orchestration_control {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -674,14 +671,12 @@ where
|
|||
if feature_config.task.enabled {
|
||||
feature_registry.add_module(task_feature);
|
||||
}
|
||||
if feature_config.ticket.enabled || feature_config.ticket_orchestration.enabled {
|
||||
let ticket_access = match feature_config.ticket.access {
|
||||
TicketFeatureAccessConfig::ReadOnly => {
|
||||
crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly
|
||||
}
|
||||
TicketFeatureAccessConfig::Lifecycle => {
|
||||
crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle
|
||||
}
|
||||
if feature_config.ticket.enabled {
|
||||
let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess {
|
||||
authoring: feature_config.ticket.authoring,
|
||||
thread: feature_config.ticket.thread,
|
||||
intake: feature_config.ticket.intake,
|
||||
orchestration_control: feature_config.ticket.orchestration_control,
|
||||
};
|
||||
// Ticket tools are typed operations over the current workspace Ticket backend.
|
||||
// Runtime-hosted Workers prefer the workspace API URI carried by the
|
||||
|
|
@ -710,10 +705,9 @@ where
|
|||
}
|
||||
};
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::ticket::ticket_tools_feature_with_options(
|
||||
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
|
||||
ticket_backend,
|
||||
feature_config.ticket.enabled.then_some(ticket_access),
|
||||
feature_config.ticket_orchestration.enabled,
|
||||
ticket_access,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@
|
|||
//! same descriptor-approved registry path used by feature modules. They are not
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
pub(crate) use session_explore::{
|
||||
SessionExploreFeature, SessionExploreState, render_extract_input,
|
||||
};
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
pub use ticket::{
|
||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
||||
ticket_tools_feature_with_options,
|
||||
ticket_tools_feature_with_backend,
|
||||
};
|
||||
|
|
|
|||
673
crates/worker/src/feature/builtin/session_explore.rs
Normal file
673
crates/worker/src/feature/builtin/session_explore.rs
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use memory::extract::{
|
||||
CandidateKind, ExtractedCandidate, StagingWriteResult, write_staging_candidate,
|
||||
};
|
||||
use memory::schema::SourceRef;
|
||||
use memory::workspace::WorkspaceLayout;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::session_reference::{
|
||||
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionReferenceView,
|
||||
ToolPart,
|
||||
};
|
||||
|
||||
const SEARCH_EVIDENCE_DESCRIPTION: &str = "Search the host-created session evidence index. Use this to find stable evidence ids before staging a memory candidate. Supports kind=user|assistant|system|tool and tool_part=input|output|both.";
|
||||
const READ_EVIDENCE_DESCRIPTION: &str = "Read bounded session evidence by evidence_id or entry_range. Use compact mode for normal verification and full mode only when exact tool arguments or result content are necessary.";
|
||||
const STAGE_CANDIDATE_DESCRIPTION: &str = "Stage one memory candidate as a flat staging record. The candidate must cite one or more evidence_ids returned by search_evidence/read_evidence.";
|
||||
const FINISH_EXTRACTION_DESCRIPTION: &str = "Finish the extract worker run after all useful candidates have been staged, or state why no candidates were useful.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionExploreState {
|
||||
view: Arc<SessionReferenceView>,
|
||||
layout: WorkspaceLayout,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
staged: Arc<Mutex<Vec<StagingWriteResult>>>,
|
||||
finished: Arc<Mutex<Option<FinishExtractionParams>>>,
|
||||
}
|
||||
|
||||
impl SessionExploreState {
|
||||
pub(crate) fn new(
|
||||
view: SessionReferenceView,
|
||||
layout: WorkspaceLayout,
|
||||
source: SourceRef,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: Arc::new(view),
|
||||
layout,
|
||||
source,
|
||||
extract_run_id: Uuid::now_v7().to_string(),
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
finished: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn view(&self) -> &SessionReferenceView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
pub(crate) fn staged(&self) -> Vec<StagingWriteResult> {
|
||||
self.staged
|
||||
.lock()
|
||||
.expect("session explore staged state poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.finished
|
||||
.lock()
|
||||
.expect("session explore finished state poisoned")
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionExploreFeature {
|
||||
state: SessionExploreState,
|
||||
}
|
||||
|
||||
impl SessionExploreFeature {
|
||||
pub(crate) fn new(state: SessionExploreState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SessionExploreFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("session-explore", "Session Explore")
|
||||
.with_description(
|
||||
"Host-bounded session evidence search/read tools plus explicit extraction staging.",
|
||||
)
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"search_evidence",
|
||||
SEARCH_EVIDENCE_DESCRIPTION,
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"read_evidence",
|
||||
READ_EVIDENCE_DESCRIPTION,
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"stage_candidate",
|
||||
STAGE_CANDIDATE_DESCRIPTION,
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"finish_extraction",
|
||||
FINISH_EXTRACTION_DESCRIPTION,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(ToolContribution::new(
|
||||
"search_evidence",
|
||||
search_evidence_definition(self.state.clone()),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"read_evidence",
|
||||
read_evidence_definition(self.state.clone()),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"stage_candidate",
|
||||
stage_candidate_definition(self.state.clone()),
|
||||
))?;
|
||||
context.tools().register(ToolContribution::new(
|
||||
"finish_extraction",
|
||||
finish_extraction_definition(self.state.clone()),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn search_evidence_definition(state: SessionExploreState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SearchEvidenceParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("search_evidence")
|
||||
.description(SEARCH_EVIDENCE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SearchEvidenceTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_evidence_definition(state: SessionExploreState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadEvidenceParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("read_evidence")
|
||||
.description(READ_EVIDENCE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadEvidenceTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn stage_candidate_definition(state: SessionExploreState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(StageCandidateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("stage_candidate")
|
||||
.description(STAGE_CANDIDATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(StageCandidateTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_extraction_definition(state: SessionExploreState) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(FinishExtractionParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("finish_extraction")
|
||||
.description(FINISH_EXTRACTION_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(FinishExtractionTool {
|
||||
state: state.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SearchEvidenceParams {
|
||||
/// Case-insensitive substring. Empty query lists bounded index entries matching filters.
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
/// Optional evidence kind: user, assistant, system, tool.
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
/// Optional tool part filter for tool evidence: input, output, both.
|
||||
#[serde(default)]
|
||||
tool_part: Option<String>,
|
||||
/// Optional tool name filter for tool input entries.
|
||||
#[serde(default)]
|
||||
tool_name: Option<String>,
|
||||
/// 0-based session item offset to start from.
|
||||
#[serde(default)]
|
||||
offset: Option<usize>,
|
||||
/// Maximum number of hits.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReadEvidenceParams {
|
||||
/// Evidence id returned by search_evidence, e.g. M0001, T0002i, T0003o.
|
||||
#[serde(default)]
|
||||
evidence_id: Option<String>,
|
||||
/// Inclusive 0-based session item range. Use when search returned an entry_range instead of a single id.
|
||||
#[serde(default)]
|
||||
entry_range: Option<[u64; 2]>,
|
||||
/// compact omits tool arguments/results; full includes them within host bounds.
|
||||
#[serde(default = "default_read_mode")]
|
||||
mode: String,
|
||||
/// Maximum entries to return.
|
||||
#[serde(default)]
|
||||
max_items: Option<usize>,
|
||||
}
|
||||
|
||||
fn default_read_mode() -> String {
|
||||
"compact".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct StageCandidateParams {
|
||||
kind: CandidateKind,
|
||||
claim: String,
|
||||
why_useful: String,
|
||||
#[serde(default)]
|
||||
staleness: Option<String>,
|
||||
#[serde(default)]
|
||||
evidence_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
struct FinishExtractionParams {
|
||||
staged_count: usize,
|
||||
#[serde(default)]
|
||||
no_candidates_reason: Option<String>,
|
||||
}
|
||||
|
||||
struct SearchEvidenceTool {
|
||||
state: SessionExploreState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SearchEvidenceTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: SearchEvidenceParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid search_evidence input: {e}"))
|
||||
})?;
|
||||
let kind = params
|
||||
.kind
|
||||
.as_deref()
|
||||
.map(parse_reference_kind)
|
||||
.transpose()?;
|
||||
let tool_part = params
|
||||
.tool_part
|
||||
.as_deref()
|
||||
.map(parse_tool_part)
|
||||
.transpose()?;
|
||||
let hits = self.state.view.search(&SearchOptions {
|
||||
query: params.query,
|
||||
kind,
|
||||
tool_part,
|
||||
tool_name: params.tool_name,
|
||||
limit: params.limit,
|
||||
min_entry_index: params.offset.map(|offset| offset as u64),
|
||||
});
|
||||
let content = hits
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let part = hit
|
||||
.tool_part
|
||||
.map(|part| format!(" {part:?}"))
|
||||
.unwrap_or_default();
|
||||
let tool = hit
|
||||
.tool_name
|
||||
.as_ref()
|
||||
.map(|name| format!(" {name}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"[{} {}{}{} {:?}] {}\n{}",
|
||||
hit.id,
|
||||
hit.kind.as_str(),
|
||||
part,
|
||||
tool,
|
||||
hit.entry_range,
|
||||
hit.label,
|
||||
hit.summary
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Found {} evidence hit(s).", hits.len()),
|
||||
content: (!content.is_empty()).then_some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadEvidenceTool {
|
||||
state: SessionExploreState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadEvidenceTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadEvidenceParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid read_evidence input: {e}")))?;
|
||||
let selector = match (params.evidence_id.as_deref(), params.entry_range) {
|
||||
(Some(id), None) => ReadSelector::Id(id),
|
||||
(None, Some(range)) => ReadSelector::EntryRange(range),
|
||||
_ => {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"read_evidence requires exactly one of evidence_id or entry_range".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let detail = parse_read_detail(¶ms.mode)?;
|
||||
let read = self.state.view.read(
|
||||
selector,
|
||||
ReadOptions {
|
||||
include_tools: true,
|
||||
tool_part: ToolPart::Both,
|
||||
detail,
|
||||
max_items: params.max_items.unwrap_or(10),
|
||||
max_bytes: 16 * 1024,
|
||||
},
|
||||
);
|
||||
let content = read
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let part = entry
|
||||
.tool_part
|
||||
.map(|part| format!(" {part:?}"))
|
||||
.unwrap_or_default();
|
||||
let tool = entry
|
||||
.tool_name
|
||||
.as_ref()
|
||||
.map(|name| format!(" {name}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"[{} {}{}{} {:?}] {}\n{}",
|
||||
entry.id,
|
||||
entry.kind.as_str(),
|
||||
part,
|
||||
tool,
|
||||
entry.entry_range,
|
||||
entry.label,
|
||||
entry.text
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let summary = if read.truncated {
|
||||
format!(
|
||||
"Read {} evidence entrie(s); output truncated.",
|
||||
read.entries.len()
|
||||
)
|
||||
} else {
|
||||
format!("Read {} evidence entrie(s).", read.entries.len())
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: (!content.is_empty()).then_some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct StageCandidateTool {
|
||||
state: SessionExploreState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for StageCandidateTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: StageCandidateParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid stage_candidate input: {e}"))
|
||||
})?;
|
||||
if params.evidence_ids.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"stage_candidate requires at least one evidence_id".to_string(),
|
||||
));
|
||||
}
|
||||
let mut evidence = Vec::with_capacity(params.evidence_ids.len());
|
||||
let mut source_refs = Vec::with_capacity(params.evidence_ids.len());
|
||||
for id in ¶ms.evidence_ids {
|
||||
let staging =
|
||||
self.state.view.staging_evidence_for(id).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(format!("unknown evidence_id {id:?}"))
|
||||
})?;
|
||||
let source_ref =
|
||||
self.state.view.source_ref_for(id).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(format!("unknown evidence_id {id:?}"))
|
||||
})?;
|
||||
evidence.push(staging);
|
||||
source_refs.push(source_ref);
|
||||
}
|
||||
let candidate = ExtractedCandidate {
|
||||
kind: params.kind,
|
||||
claim: params.claim,
|
||||
why_useful: params.why_useful,
|
||||
staleness: params.staleness,
|
||||
evidence_ids: params.evidence_ids,
|
||||
};
|
||||
let written = write_staging_candidate(
|
||||
&self.state.layout,
|
||||
self.state.source.clone(),
|
||||
&self.state.extract_run_id,
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("write staging failed: {e}")))?;
|
||||
let id = written.id.to_string();
|
||||
self.state
|
||||
.staged
|
||||
.lock()
|
||||
.expect("session explore staged state poisoned")
|
||||
.push(written);
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Staged memory candidate {id}."),
|
||||
content: Some(format!("staging_id: {id}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FinishExtractionTool {
|
||||
state: SessionExploreState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FinishExtractionTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: FinishExtractionParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid finish_extraction input: {e}"))
|
||||
})?;
|
||||
let actual = self
|
||||
.state
|
||||
.staged
|
||||
.lock()
|
||||
.expect("session explore staged state poisoned")
|
||||
.len();
|
||||
if params.staged_count != actual {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"finish_extraction staged_count {} does not match actual staged count {actual}",
|
||||
params.staged_count
|
||||
)));
|
||||
}
|
||||
let reason = params.no_candidates_reason.clone();
|
||||
*self
|
||||
.state
|
||||
.finished
|
||||
.lock()
|
||||
.expect("session explore finished state poisoned") = Some(params);
|
||||
Ok(ToolOutput {
|
||||
summary: reason
|
||||
.map(|reason| {
|
||||
format!("Finished extraction with {actual} staged candidate(s): {reason}")
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
format!("Finished extraction with {actual} staged candidate(s).")
|
||||
}),
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_reference_kind(value: &str) -> Result<ReferenceKind, ToolError> {
|
||||
ReferenceKind::parse(value).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"invalid kind {value:?}; expected user, assistant/agent, system, or tool"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tool_part(value: &str) -> Result<ToolPart, ToolError> {
|
||||
ToolPart::parse(value).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"invalid tool_part {value:?}; expected input, output, or both"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_read_detail(value: &str) -> Result<ReadDetail, ToolError> {
|
||||
match value {
|
||||
"compact" => Ok(ReadDetail::Compact),
|
||||
"full" => Ok(ReadDetail::Full),
|
||||
other => Err(ToolError::InvalidArgument(format!(
|
||||
"invalid read mode {other:?}; expected compact or full"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_extract_input(view: &SessionReferenceView) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Session overview\n\n");
|
||||
if view.overview().is_empty() {
|
||||
out.push_str("No user/assistant overview entries are available.\n\n");
|
||||
} else {
|
||||
for item in view.overview() {
|
||||
out.push_str(&format!(
|
||||
"- [{} {} {:?}] {}\n {}\n",
|
||||
item.id,
|
||||
item.kind.as_str(),
|
||||
item.entry_range,
|
||||
item.label,
|
||||
truncate_line(&item.text, 500)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out.push_str("# Initial evidence index\n\n");
|
||||
out.push_str(
|
||||
"Use search_evidence/read_evidence to inspect details. Cite only M/T evidence ids in stage_candidate.evidence_ids.\n\n",
|
||||
);
|
||||
let hits = view.search(&SearchOptions {
|
||||
query: String::new(),
|
||||
kind: None,
|
||||
tool_part: None,
|
||||
tool_name: None,
|
||||
limit: Some(50),
|
||||
min_entry_index: None,
|
||||
});
|
||||
for hit in hits {
|
||||
let part = hit
|
||||
.tool_part
|
||||
.map(|part| format!(" {part:?}"))
|
||||
.unwrap_or_default();
|
||||
let tool = hit
|
||||
.tool_name
|
||||
.as_ref()
|
||||
.map(|name| format!(" {name}"))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"- [{} {}{}{} {:?}] {} — {}\n",
|
||||
hit.id,
|
||||
hit.kind.as_str(),
|
||||
part,
|
||||
tool,
|
||||
hit.entry_range,
|
||||
hit.label,
|
||||
hit.summary
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn truncate_line(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.replace('\n', " ");
|
||||
if normalized.chars().count() <= max_chars {
|
||||
normalized
|
||||
} else {
|
||||
let mut out = normalized.chars().take(max_chars).collect::<String>();
|
||||
out.push_str("…");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_engine::Item;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_session_explore_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let state = SessionExploreState::new(
|
||||
SessionReferenceView::new("segment-1", vec![Item::user_message("remember this")]),
|
||||
WorkspaceLayout::new(temp.path()),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
},
|
||||
);
|
||||
let descriptor = SessionExploreFeature::new(state).descriptor();
|
||||
assert_eq!(descriptor.id.as_str(), "builtin:session-explore");
|
||||
let names = descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"search_evidence",
|
||||
"read_evidence",
|
||||
"stage_candidate",
|
||||
"finish_extraction"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_extract_input_exposes_overview_and_evidence_ids() {
|
||||
let view = SessionReferenceView::new(
|
||||
"segment-1",
|
||||
vec![
|
||||
Item::user_message("user preference"),
|
||||
Item::assistant_message("assistant reply"),
|
||||
Item::tool_call("c1", "Read", "{}"),
|
||||
Item::tool_result_with_content("c1", "read ok", "file body"),
|
||||
],
|
||||
);
|
||||
let input = render_extract_input(&view);
|
||||
assert!(input.contains("# Session overview"));
|
||||
assert!(input.contains("M0000"));
|
||||
assert!(input.contains("T0002i"));
|
||||
assert!(input.contains("T0003o"));
|
||||
assert!(input.contains("stage_candidate.evidence_ids"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_candidate_writes_staging_record_with_source_evidence() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let state = SessionExploreState::new(
|
||||
SessionReferenceView::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
WorkspaceLayout::new(temp.path()),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
},
|
||||
);
|
||||
let tool = StageCandidateTool {
|
||||
state: state.clone(),
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"kind": "decision",
|
||||
"claim": "Use host-created evidence anchors for extract staging.",
|
||||
"why_useful": "Future consolidation can trust bounded source refs.",
|
||||
"evidence_ids": ["M0000"]
|
||||
})
|
||||
.to_string(),
|
||||
llm_engine::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let staged = state.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
let bytes = std::fs::read(&staged[0].path).unwrap();
|
||||
let record: memory::extract::StagingRecord = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(record.kind, CandidateKind::Decision);
|
||||
assert_eq!(record.evidence.len(), 1);
|
||||
assert_eq!(record.evidence[0].id, "M0000");
|
||||
assert_eq!(record.source_refs.len(), 1);
|
||||
assert_eq!(record.source_refs[0].evidence_id.as_deref(), Some("M0000"));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,16 +10,11 @@ use ticket::{
|
|||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
||||
TicketReview, TicketStateChange, TicketSummary,
|
||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
|
||||
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
|
||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||
tool::{
|
||||
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
||||
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||
TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description,
|
||||
ticket_tools,
|
||||
},
|
||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
|
|
@ -32,30 +27,146 @@ const FEATURE_NAME: &str = "Ticket tools";
|
|||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TicketFeatureAccess {
|
||||
/// Status/diagnostic access for views such as Companion that must not mutate Tickets.
|
||||
ReadOnly,
|
||||
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools.
|
||||
Lifecycle,
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TicketFeatureAccess {
|
||||
pub authoring: bool,
|
||||
pub thread: bool,
|
||||
pub intake: bool,
|
||||
pub orchestration_control: bool,
|
||||
}
|
||||
|
||||
impl TicketFeatureAccess {
|
||||
pub fn base_tool_names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES,
|
||||
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES,
|
||||
pub const fn read_only() -> Self {
|
||||
Self {
|
||||
authoring: false,
|
||||
thread: false,
|
||||
intake: false,
|
||||
orchestration_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES,
|
||||
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||
pub const fn workspace_authoring() -> Self {
|
||||
Self {
|
||||
authoring: true,
|
||||
thread: true,
|
||||
intake: false,
|
||||
orchestration_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn intake() -> Self {
|
||||
Self {
|
||||
authoring: true,
|
||||
thread: true,
|
||||
intake: true,
|
||||
orchestration_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn orchestration_control() -> Self {
|
||||
Self {
|
||||
authoring: false,
|
||||
thread: true,
|
||||
intake: false,
|
||||
orchestration_control: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn work_report() -> Self {
|
||||
Self {
|
||||
authoring: false,
|
||||
thread: true,
|
||||
intake: false,
|
||||
orchestration_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn review() -> Self {
|
||||
Self {
|
||||
authoring: false,
|
||||
thread: true,
|
||||
intake: false,
|
||||
orchestration_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_names(self) -> Vec<&'static str> {
|
||||
TICKET_TOOL_NAMES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|name| self.allows_tool(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn allows_tool(self, name: &str) -> bool {
|
||||
READ_ONLY_TOOL_NAMES.contains(&name)
|
||||
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|
||||
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|
||||
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|
||||
|| (self.orchestration_control
|
||||
&& ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name))
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
];
|
||||
|
||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
|
||||
|
||||
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||
|
||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketReview",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TicketFeatureBackend {
|
||||
Local {
|
||||
|
|
@ -96,94 +207,59 @@ pub struct TicketFeature {
|
|||
record_language: Option<String>,
|
||||
config_error: Option<String>,
|
||||
access: TicketFeatureAccess,
|
||||
include_base_tools: bool,
|
||||
include_orchestration_tools: bool,
|
||||
}
|
||||
|
||||
impl TicketFeature {
|
||||
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
||||
Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle)
|
||||
Self::new_with_access(backend_root, TicketFeatureAccess::workspace_authoring())
|
||||
}
|
||||
|
||||
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
||||
Self::new_with_options(backend_root, Some(access), true)
|
||||
}
|
||||
|
||||
pub fn new_with_options(
|
||||
backend_root: impl Into<PathBuf>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
Self::with_backend(
|
||||
TicketFeatureBackend::Local {
|
||||
root: backend_root.into(),
|
||||
},
|
||||
access,
|
||||
include_orchestration_tools,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_backend(
|
||||
backend: TicketFeatureBackend,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
pub fn with_backend(backend: TicketFeatureBackend, access: TicketFeatureAccess) -> Self {
|
||||
if let TicketFeatureBackend::LocalWorkspace { workspace_root } = backend {
|
||||
return Self::for_workspace_with_options(
|
||||
workspace_root,
|
||||
access,
|
||||
include_orchestration_tools,
|
||||
);
|
||||
return Self::for_workspace_with_access(workspace_root, access);
|
||||
}
|
||||
Self {
|
||||
backend,
|
||||
record_language: None,
|
||||
config_error: None,
|
||||
access: access.unwrap_or(TicketFeatureAccess::Lifecycle),
|
||||
include_base_tools: access.is_some(),
|
||||
include_orchestration_tools,
|
||||
access,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
||||
Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle)
|
||||
Self::for_workspace_with_access(workspace, TicketFeatureAccess::workspace_authoring())
|
||||
}
|
||||
|
||||
pub fn for_workspace_with_access(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: TicketFeatureAccess,
|
||||
) -> Self {
|
||||
Self::for_workspace_with_options(workspace, Some(access), true)
|
||||
}
|
||||
|
||||
pub fn for_workspace_with_options(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
let workspace = workspace.as_ref();
|
||||
match TicketConfig::load_workspace(workspace) {
|
||||
Ok(config) => {
|
||||
let backend_root = config.backend_root().to_path_buf();
|
||||
let record_language = config.ticket_record_language().map(str::to_string);
|
||||
let mut feature =
|
||||
Self::new_with_options(backend_root, access, include_orchestration_tools);
|
||||
let mut feature = Self::new_with_access(backend_root, access);
|
||||
feature.record_language = record_language;
|
||||
feature
|
||||
}
|
||||
Err(error) => {
|
||||
let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle);
|
||||
Self {
|
||||
Err(error) => Self {
|
||||
backend: TicketFeatureBackend::Local {
|
||||
root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||
},
|
||||
record_language: None,
|
||||
config_error: Some(error.to_string()),
|
||||
access: access_value,
|
||||
include_base_tools: access.is_some(),
|
||||
include_orchestration_tools,
|
||||
}
|
||||
}
|
||||
access,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,20 +276,7 @@ impl TicketFeature {
|
|||
}
|
||||
|
||||
fn enabled_tool_names(&self) -> Vec<&'static str> {
|
||||
if self.include_base_tools && self.include_orchestration_tools {
|
||||
return match self.access {
|
||||
TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(),
|
||||
TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(),
|
||||
};
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
if self.include_base_tools {
|
||||
names.extend_from_slice(self.access.base_tool_names());
|
||||
}
|
||||
if self.include_orchestration_tools {
|
||||
names.extend_from_slice(self.access.orchestration_tool_names());
|
||||
}
|
||||
names
|
||||
self.access.tool_names()
|
||||
}
|
||||
|
||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||
|
|
@ -271,9 +334,9 @@ impl FeatureModule for TicketFeature {
|
|||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
let enabled_tool_names = self.enabled_tool_names();
|
||||
for name in &enabled_tool_names {
|
||||
for name in enabled_tool_names {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
*name,
|
||||
name,
|
||||
ticket_tool_description(name, self.record_language.as_deref()),
|
||||
));
|
||||
}
|
||||
|
|
@ -313,7 +376,6 @@ impl FeatureModule for TicketFeature {
|
|||
struct WorkspaceHttpTicketBackend {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl WorkspaceHttpTicketBackend {
|
||||
|
|
@ -321,7 +383,6 @@ impl WorkspaceHttpTicketBackend {
|
|||
Self {
|
||||
workspace_id,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,13 +396,27 @@ impl WorkspaceHttpTicketBackend {
|
|||
fn invoke(
|
||||
&self,
|
||||
operation: TicketBackendOperation,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let endpoint = self.endpoint();
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
return std::thread::spawn(move || Self::invoke_http(endpoint, operation))
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
TicketError::Conflict("ticket backend request thread panicked".to_string())
|
||||
})?;
|
||||
}
|
||||
Self::invoke_http(endpoint, operation)
|
||||
}
|
||||
|
||||
fn invoke_http(
|
||||
endpoint: String,
|
||||
operation: TicketBackendOperation,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let body = serde_json::to_string(&operation).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize ticket operation: {error}"))
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.post(self.endpoint())
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.post(endpoint)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
|
|
@ -388,7 +463,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||
}
|
||||
}
|
||||
|
||||
fn list(&self, filter: TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::List { filter }),
|
||||
TicketBackendOperationResult::Tickets
|
||||
|
|
@ -409,6 +484,20 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||
)
|
||||
}
|
||||
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> TicketResult<Ticket> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::EditItem { id, edit }),
|
||||
TicketBackendOperationResult::Ticket
|
||||
)
|
||||
}
|
||||
|
||||
fn dependency_check(&self, id: TicketIdOrSlug) -> TicketResult<ticket::TicketDependencyCheck> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::DependencyCheck { id }),
|
||||
TicketBackendOperationResult::DependencyCheck
|
||||
)
|
||||
}
|
||||
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> TicketResult<()> {
|
||||
match self.invoke(TicketBackendOperation::AddEvent { id, event })? {
|
||||
TicketBackendOperationResult::Unit => Ok(()),
|
||||
|
|
@ -589,12 +678,11 @@ pub fn ticket_tools_feature_with_access(
|
|||
TicketFeature::for_workspace_with_access(workspace, access)
|
||||
}
|
||||
|
||||
pub fn ticket_tools_feature_with_options(
|
||||
pub fn ticket_tools_feature_with_backend(
|
||||
backend: impl Into<TicketFeatureBackend>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
access: TicketFeatureAccess,
|
||||
) -> TicketFeature {
|
||||
TicketFeature::with_backend(backend.into(), access, include_orchestration_tools)
|
||||
TicketFeature::with_backend(backend.into(), access)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -606,10 +694,6 @@ mod tests {
|
|||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use tempfile::TempDir;
|
||||
use ticket::tool::{
|
||||
TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES,
|
||||
TICKET_TOOL_NAMES,
|
||||
};
|
||||
|
||||
fn make_ticket_root(root: &Path) {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
|
|
@ -641,66 +725,114 @@ mod tests {
|
|||
let descriptor = feature.descriptor();
|
||||
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||
assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(descriptor.tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_TOOL_NAMES
|
||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_descriptor_declares_only_state_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
||||
let feature =
|
||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly);
|
||||
assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(feature.access(), TicketFeatureAccess::read_only());
|
||||
assert_eq!(descriptor.tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_can_expose_base_ticket_without_orchestration_tools() {
|
||||
fn orchestration_control_descriptor_declares_orchestration_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_options(
|
||||
let feature = ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
Some(TicketFeatureAccess::Lifecycle),
|
||||
false,
|
||||
TicketFeatureAccess::orchestration_control(),
|
||||
);
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
feature.access(),
|
||||
TicketFeatureAccess::orchestration_control()
|
||||
);
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_BASE_TOOL_NAMES
|
||||
ORCHESTRATION_CONTROL_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_can_expose_orchestration_only_tools() {
|
||||
fn additive_ticket_capabilities_expose_expected_tool_surfaces() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_options(temp.path(), None, true);
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
|
||||
let workspace_authoring = ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
);
|
||||
let workspace_descriptor = workspace_authoring.descriptor();
|
||||
let workspace_tools = workspace_descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_ORCHESTRATION_TOOL_NAMES
|
||||
.collect::<Vec<_>>();
|
||||
assert!(workspace_tools.contains(&"TicketCreate"));
|
||||
assert!(workspace_tools.contains(&"TicketEditItem"));
|
||||
assert!(workspace_tools.contains(&"TicketQueue"));
|
||||
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
|
||||
|
||||
let orchestration = ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::orchestration_control(),
|
||||
);
|
||||
let orchestration_descriptor = orchestration.descriptor();
|
||||
let orchestration_tools = orchestration_descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(orchestration_tools.contains(&"TicketWorkflowState"));
|
||||
assert!(orchestration_tools.contains(&"TicketDependencyCheck"));
|
||||
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
|
||||
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
|
||||
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
||||
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
||||
|
||||
let work_report =
|
||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::work_report());
|
||||
let work_report_descriptor = work_report.descriptor();
|
||||
let work_report_tools = work_report_descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(work_report_tools.contains(&"TicketComment"));
|
||||
assert!(work_report_tools.contains(&"TicketReview"));
|
||||
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
||||
|
||||
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
||||
let review_descriptor = review.descriptor();
|
||||
let review_tools = review_descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(review_tools.contains(&"TicketReview"));
|
||||
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -712,20 +844,17 @@ mod tests {
|
|||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::ReadOnly,
|
||||
TicketFeatureAccess::read_only(),
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
||||
let pending_names = pending_tools
|
||||
.iter()
|
||||
.map(|definition| definition().0.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pending_names, TICKET_READ_ONLY_TOOL_NAMES);
|
||||
assert_eq!(pending_names, READ_ONLY_TOOL_NAMES);
|
||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
||||
assert!(
|
||||
!report.reports[0]
|
||||
|
|
@ -748,7 +877,8 @@ language = "Japanese"
|
|||
"#,
|
||||
);
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
||||
let feature =
|
||||
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::read_only());
|
||||
let descriptor = feature.descriptor();
|
||||
let descriptor_description = descriptor
|
||||
.tools
|
||||
|
|
@ -765,11 +895,8 @@ language = "Japanese"
|
|||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
assert_eq!(pending_tools.len(), READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, READ_ONLY_TOOL_NAMES);
|
||||
let description = pending_tool_description(&pending_tools, "TicketShow");
|
||||
assert!(description.contains("Ticket record language: Japanese"));
|
||||
assert!(description.contains("distinct from worker.language"));
|
||||
|
|
@ -777,7 +904,7 @@ language = "Japanese"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_installation_exposes_lifecycle_tools() {
|
||||
fn workspace_authoring_installation_exposes_authoring_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let mut pending_tools = Vec::new();
|
||||
|
|
@ -785,36 +912,31 @@ language = "Japanese"
|
|||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::Lifecycle,
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
||||
assert!(
|
||||
report.reports[0]
|
||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
let installed = report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == name)
|
||||
);
|
||||
}
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||
assert!(
|
||||
report.reports[0]
|
||||
.installed_tools
|
||||
!installed
|
||||
.iter()
|
||||
.any(|tool| tool == "TicketIntakeReady")
|
||||
);
|
||||
assert!(
|
||||
report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "TicketWorkflowState")
|
||||
.any(|tool| *tool == "TicketOrchestrationPlanRecord")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() {
|
||||
fn workspace_authoring_context_exposes_ticket_language_guidance() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
|
|
@ -829,12 +951,15 @@ language = "Japanese"
|
|||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::Lifecycle,
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||
);
|
||||
let description = pending_tool_description(&pending_tools, "TicketComment");
|
||||
assert!(description.contains("Ticket record language: Japanese"));
|
||||
assert!(description.contains("durable Ticket record and Ticket tool body text"));
|
||||
|
|
@ -852,10 +977,13 @@ language = "Japanese"
|
|||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports.len(), 1);
|
||||
assert!(report.reports[0].installed);
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||
);
|
||||
assert!(report.reports[0].skipped.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -887,7 +1015,7 @@ profile = "project:coder"
|
|||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -973,14 +1101,31 @@ provider = "github"
|
|||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
assert_eq!(pending_tools.len(), WORKSPACE_AUTHORING_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||
);
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
assert!(!root.join("open").exists());
|
||||
assert!(!root.join("pending").exists());
|
||||
assert!(!root.join("closed").exists());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn workspace_http_backend_invoke_is_safe_inside_async_context() {
|
||||
let backend =
|
||||
WorkspaceHttpTicketBackend::new("workspace-a".to_string(), "not-a-url".to_string());
|
||||
|
||||
let error = backend
|
||||
.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody {
|
||||
from: "planning".to_string(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("ticket backend request failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
|
|
|
|||
|
|
@ -2552,8 +2552,39 @@ struct TungstenitePluginWebSocketClient;
|
|||
type AsyncSystemWebSocket =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
struct PluginWebSocketRuntime {
|
||||
runtime: Option<TokioRuntime>,
|
||||
}
|
||||
|
||||
impl PluginWebSocketRuntime {
|
||||
fn new(runtime: TokioRuntime) -> Self {
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self) -> &TokioRuntime {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.expect("plugin websocket runtime missing")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginWebSocketRuntime {
|
||||
fn drop(&mut self) {
|
||||
let Some(runtime) = self.runtime.take() else {
|
||||
return;
|
||||
};
|
||||
if TokioHandle::try_current().is_ok() {
|
||||
let _ = tokio::task::spawn_blocking(move || drop(runtime));
|
||||
} else {
|
||||
drop(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TungstenitePluginWebSocketConnection {
|
||||
runtime: TokioRuntime,
|
||||
runtime: PluginWebSocketRuntime,
|
||||
socket: AsyncSystemWebSocket,
|
||||
}
|
||||
|
||||
|
|
@ -2592,7 +2623,7 @@ impl PluginWebSocketClient for TungstenitePluginWebSocketClient {
|
|||
)
|
||||
.await
|
||||
};
|
||||
let (socket, _response) = block_on_websocket_future(&runtime, open)
|
||||
let (socket, _response) = block_on_websocket_future(runtime.get(), open)
|
||||
.map_err(|error| {
|
||||
PluginWebSocketError::new(format!(
|
||||
"WebSocket open timed out after {} ms for {}: {error}",
|
||||
|
|
@ -2619,7 +2650,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection {
|
|||
PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT,
|
||||
self.socket.send(Message::Text(text.to_string().into())),
|
||||
);
|
||||
block_on_websocket_future(&self.runtime, send)
|
||||
block_on_websocket_future(self.runtime.get(), send)
|
||||
.map_err(|_| PluginWebSocketError::new("WebSocket send timed out"))?
|
||||
.map_err(|error| PluginWebSocketError::new(format!("WebSocket send failed: {error}")))
|
||||
}
|
||||
|
|
@ -2631,7 +2662,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection {
|
|||
) -> Result<PluginWebSocketRecvResponse, PluginWebSocketError> {
|
||||
for _ in 0..PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES {
|
||||
let next = tokio::time::timeout(timeout, self.socket.next());
|
||||
let message = block_on_websocket_future(&self.runtime, next)
|
||||
let message = block_on_websocket_future(self.runtime.get(), next)
|
||||
.map_err(|_| PluginWebSocketError::new("WebSocket receive timed out"))?
|
||||
.ok_or_else(|| PluginWebSocketError::new("WebSocket stream ended"))?
|
||||
.map_err(|error| {
|
||||
|
|
@ -2660,7 +2691,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection {
|
|||
PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT,
|
||||
self.socket.send(Message::Pong(payload)),
|
||||
);
|
||||
block_on_websocket_future(&self.runtime, send)
|
||||
block_on_websocket_future(self.runtime.get(), send)
|
||||
.map_err(|_| PluginWebSocketError::new("WebSocket pong timed out"))?
|
||||
.map_err(|error| {
|
||||
PluginWebSocketError::new(format!("WebSocket pong failed: {error}"))
|
||||
|
|
@ -2676,19 +2707,20 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection {
|
|||
|
||||
fn close(&mut self) -> Result<(), PluginWebSocketError> {
|
||||
let close = tokio::time::timeout(PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.close(None));
|
||||
block_on_websocket_future(&self.runtime, close)
|
||||
block_on_websocket_future(self.runtime.get(), close)
|
||||
.map_err(|_| PluginWebSocketError::new("WebSocket close timed out"))?
|
||||
.map_err(|error| PluginWebSocketError::new(format!("WebSocket close failed: {error}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn new_websocket_runtime() -> Result<TokioRuntime, PluginWebSocketError> {
|
||||
TokioRuntimeBuilder::new_current_thread()
|
||||
fn new_websocket_runtime() -> Result<PluginWebSocketRuntime, PluginWebSocketError> {
|
||||
let runtime = TokioRuntimeBuilder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
PluginWebSocketError::new(format!("WebSocket runtime build failed: {error}"))
|
||||
})
|
||||
})?;
|
||||
Ok(PluginWebSocketRuntime::new(runtime))
|
||||
}
|
||||
|
||||
fn block_on_websocket_future<F: std::future::Future>(
|
||||
|
|
@ -5656,6 +5688,12 @@ mod tests {
|
|||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn websocket_runtime_drop_is_safe_inside_async_context() {
|
||||
let runtime = new_websocket_runtime().unwrap();
|
||||
drop(runtime);
|
||||
}
|
||||
|
||||
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
|
||||
manifest::plugin::PluginToolManifest {
|
||||
name: name.into(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod model_client;
|
|||
pub mod prompt;
|
||||
pub mod runtime;
|
||||
pub mod segment_log_sink;
|
||||
mod session_reference;
|
||||
pub mod shared_state;
|
||||
mod shutdown_after_idle;
|
||||
pub mod skill;
|
||||
|
|
|
|||
580
crates/worker/src/session_reference.rs
Normal file
580
crates/worker/src/session_reference.rs
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
//! Immutable reference view over a session history slice.
|
||||
//!
|
||||
//! This module is shared substrate for internal workers that need to inspect a
|
||||
//! bounded, host-created view of session history without reading the live
|
||||
//! foreground Worker state directly.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llm_engine::{Item, Role};
|
||||
use memory::extract::StagingEvidence;
|
||||
use memory::schema::{EvidenceKind, SourceEvidenceRef};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||
const MAX_SEARCH_LIMIT: usize = 50;
|
||||
const DEFAULT_READ_MAX_ITEMS: usize = 40;
|
||||
const MAX_READ_MAX_ITEMS: usize = 80;
|
||||
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ReferenceKind {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl ReferenceKind {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Assistant => "assistant",
|
||||
Self::System => "system",
|
||||
Self::Tool => "tool",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"user" => Some(Self::User),
|
||||
"assistant" | "agent" => Some(Self::Assistant),
|
||||
"system" => Some(Self::System),
|
||||
"tool" => Some(Self::Tool),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_kind(self) -> EvidenceKind {
|
||||
EvidenceKind::new(EvidenceKind::MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ToolPart {
|
||||
Input,
|
||||
Output,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ToolPart {
|
||||
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"input" => Some(Self::Input),
|
||||
"output" => Some(Self::Output),
|
||||
"both" => Some(Self::Both),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(self, actual: ToolPart) -> bool {
|
||||
matches!(self, ToolPart::Both) || self == actual
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OverviewItem {
|
||||
pub id: String,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub label: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReferenceEntry {
|
||||
pub id: String,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
pub label: String,
|
||||
pub summary: String,
|
||||
search_text: String,
|
||||
}
|
||||
|
||||
impl ReferenceEntry {
|
||||
fn evidence_kind(&self) -> EvidenceKind {
|
||||
match (self.kind, self.tool_part) {
|
||||
(ReferenceKind::Tool, Some(ToolPart::Input)) => {
|
||||
EvidenceKind::new(EvidenceKind::TOOL_CALL)
|
||||
}
|
||||
(ReferenceKind::Tool, Some(ToolPart::Output | ToolPart::Both) | None) => {
|
||||
EvidenceKind::new(EvidenceKind::TOOL_RESULT)
|
||||
}
|
||||
_ => self.kind.evidence_kind(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SearchOptions {
|
||||
pub query: String,
|
||||
pub kind: Option<ReferenceKind>,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub min_entry_index: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SearchHit {
|
||||
pub id: String,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
pub entry_range: [u64; 2],
|
||||
pub label: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ReadSelector<'a> {
|
||||
Id(&'a str),
|
||||
EntryRange([u64; 2]),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ReadDetail {
|
||||
Compact,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReadOptions {
|
||||
pub include_tools: bool,
|
||||
pub tool_part: ToolPart,
|
||||
pub detail: ReadDetail,
|
||||
pub max_items: usize,
|
||||
pub max_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for ReadOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_tools: true,
|
||||
tool_part: ToolPart::Both,
|
||||
detail: ReadDetail::Compact,
|
||||
max_items: DEFAULT_READ_MAX_ITEMS,
|
||||
max_bytes: DEFAULT_READ_MAX_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReadEntry {
|
||||
pub id: String,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
pub entry_range: [u64; 2],
|
||||
pub label: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReadResult {
|
||||
pub entries: Vec<ReadEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionReferenceView {
|
||||
segment_id: String,
|
||||
items: Arc<Vec<Item>>,
|
||||
overview: Vec<OverviewItem>,
|
||||
index: Vec<ReferenceEntry>,
|
||||
}
|
||||
|
||||
impl SessionReferenceView {
|
||||
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
||||
let segment_id = segment_id.into();
|
||||
let items = Arc::new(items);
|
||||
let mut overview = Vec::new();
|
||||
let mut index = Vec::new();
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let entry_range = [idx as u64, idx as u64];
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let kind = match role {
|
||||
Role::User => ReferenceKind::User,
|
||||
Role::Assistant => ReferenceKind::Assistant,
|
||||
Role::System => ReferenceKind::System,
|
||||
};
|
||||
let text = content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let label = format!("{} message", kind.as_str());
|
||||
let summary = truncate_chars(&text, 240);
|
||||
let id = format!("M{idx:04}");
|
||||
index.push(ReferenceEntry {
|
||||
id: id.clone(),
|
||||
entry_range,
|
||||
kind,
|
||||
tool_part: None,
|
||||
tool_name: None,
|
||||
label: label.clone(),
|
||||
summary: summary.clone(),
|
||||
search_text: text.clone(),
|
||||
});
|
||||
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
||||
overview.push(OverviewItem {
|
||||
id: format!("O{:04}", overview.len()),
|
||||
entry_range,
|
||||
kind,
|
||||
label,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => {
|
||||
let text = format!("{name}\n{arguments}");
|
||||
index.push(ReferenceEntry {
|
||||
id: format!("T{idx:04}i"),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Input),
|
||||
tool_name: Some(name.clone()),
|
||||
label: format!("tool input: {name}"),
|
||||
summary: format!("Tool call {name}"),
|
||||
search_text: text,
|
||||
});
|
||||
}
|
||||
Item::ToolResult {
|
||||
summary, content, ..
|
||||
} => {
|
||||
let text = format!("{summary}\n{}", content.as_deref().unwrap_or_default());
|
||||
index.push(ReferenceEntry {
|
||||
id: format!("T{idx:04}o"),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Output),
|
||||
tool_name: None,
|
||||
label: "tool output".to_string(),
|
||||
summary: truncate_chars(summary, 240),
|
||||
search_text: text,
|
||||
});
|
||||
}
|
||||
Item::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
segment_id,
|
||||
items,
|
||||
overview,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn overview(&self) -> &[OverviewItem] {
|
||||
&self.overview
|
||||
}
|
||||
|
||||
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
|
||||
let query = options.query.trim().to_lowercase();
|
||||
let limit = options
|
||||
.limit
|
||||
.unwrap_or(DEFAULT_SEARCH_LIMIT)
|
||||
.clamp(1, MAX_SEARCH_LIMIT);
|
||||
let tool_name = options.tool_name.as_deref();
|
||||
let min_entry_index = options.min_entry_index.unwrap_or(0);
|
||||
let mut hits = Vec::new();
|
||||
|
||||
for entry in &self.index {
|
||||
if entry.entry_range[0] < min_entry_index {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = options.kind {
|
||||
if entry.kind != kind {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(part) = options.tool_part {
|
||||
if entry.kind != ReferenceKind::Tool {
|
||||
continue;
|
||||
}
|
||||
let Some(actual) = entry.tool_part else {
|
||||
continue;
|
||||
};
|
||||
if !part.matches(actual) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(name) = tool_name {
|
||||
if entry.tool_name.as_deref() != Some(name) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
|
||||
continue;
|
||||
}
|
||||
hits.push(SearchHit {
|
||||
id: entry.id.clone(),
|
||||
kind: entry.kind,
|
||||
tool_part: entry.tool_part,
|
||||
tool_name: entry.tool_name.clone(),
|
||||
entry_range: entry.entry_range,
|
||||
label: entry.label.clone(),
|
||||
summary: entry.summary.clone(),
|
||||
});
|
||||
if hits.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hits
|
||||
}
|
||||
|
||||
pub(crate) fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult {
|
||||
let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS);
|
||||
let max_bytes = options.max_bytes.max(1);
|
||||
let mut entries = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
let mut truncated = false;
|
||||
|
||||
let selected: Vec<&ReferenceEntry> = match selector {
|
||||
ReadSelector::Id(id) => self.index.iter().filter(|entry| entry.id == id).collect(),
|
||||
ReadSelector::EntryRange([start, end]) => self
|
||||
.index
|
||||
.iter()
|
||||
.filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
for entry in selected {
|
||||
if entries.len() >= max_items {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if entry.kind == ReferenceKind::Tool {
|
||||
if !options.include_tools {
|
||||
continue;
|
||||
}
|
||||
if let Some(actual) = entry.tool_part {
|
||||
if !options.tool_part.matches(actual) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
|
||||
continue;
|
||||
};
|
||||
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
|
||||
bytes = bytes.saturating_add(text.len());
|
||||
entries.push(ReadEntry {
|
||||
id: entry.id.clone(),
|
||||
kind: entry.kind,
|
||||
tool_part: entry.tool_part,
|
||||
tool_name: entry.tool_name.clone(),
|
||||
entry_range: entry.entry_range,
|
||||
label: entry.label.clone(),
|
||||
text,
|
||||
});
|
||||
if bytes >= max_bytes {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ReadResult { entries, truncated }
|
||||
}
|
||||
|
||||
pub(crate) fn source_ref_for(&self, id: &str) -> Option<SourceEvidenceRef> {
|
||||
let entry = self.index.iter().find(|entry| entry.id == id)?;
|
||||
Some(SourceEvidenceRef {
|
||||
segment_id: Some(self.segment_id.clone()),
|
||||
entry_range: Some(entry.entry_range),
|
||||
evidence_id: Some(entry.id.clone()),
|
||||
evidence_kind: Some(entry.evidence_kind()),
|
||||
label: Some(entry.label.clone()),
|
||||
summary: Some(entry.summary.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn staging_evidence_for(&self, id: &str) -> Option<StagingEvidence> {
|
||||
let entry = self.index.iter().find(|entry| entry.id == id)?;
|
||||
let read = self.read(
|
||||
ReadSelector::Id(id),
|
||||
ReadOptions {
|
||||
include_tools: true,
|
||||
tool_part: ToolPart::Both,
|
||||
detail: ReadDetail::Compact,
|
||||
max_items: 1,
|
||||
max_bytes: 2 * 1024,
|
||||
},
|
||||
);
|
||||
let excerpt = read
|
||||
.entries
|
||||
.first()
|
||||
.map(|entry| entry.text.clone())
|
||||
.unwrap_or_else(|| entry.summary.clone());
|
||||
Some(StagingEvidence {
|
||||
id: entry.id.clone(),
|
||||
kind: entry.evidence_kind(),
|
||||
entry_range: Some(entry.entry_range),
|
||||
excerpt: Some(excerpt),
|
||||
summary: Some(entry.summary.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
item: &Item,
|
||||
entry: &ReferenceEntry,
|
||||
detail: ReadDetail,
|
||||
max_bytes: usize,
|
||||
) -> String {
|
||||
let text = match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let text = content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!("[{} {:?}] {text}", entry.id, role)
|
||||
}
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => match detail {
|
||||
ReadDetail::Compact => format!("[{} ToolInput {name}] (arguments omitted)", entry.id),
|
||||
ReadDetail::Full => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id),
|
||||
},
|
||||
Item::ToolResult {
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => match detail {
|
||||
ReadDetail::Compact => format!(
|
||||
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted)",
|
||||
entry.id,
|
||||
if *is_error { " error" } else { "" }
|
||||
),
|
||||
ReadDetail::Full => format!(
|
||||
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}",
|
||||
entry.id,
|
||||
if *is_error { " error" } else { "" },
|
||||
content.as_deref().unwrap_or_default()
|
||||
),
|
||||
},
|
||||
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
|
||||
};
|
||||
truncate_chars(&text, max_bytes)
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
if max_chars == 0 {
|
||||
return "… [truncated]".to_string();
|
||||
}
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out = text.chars().take(max_chars).collect::<String>();
|
||||
out.push_str("… [truncated]");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overview_contains_user_and_assistant_only() {
|
||||
let view = SessionReferenceView::new(
|
||||
"segment-1",
|
||||
vec![
|
||||
Item::system_message("sys"),
|
||||
Item::user_message("hello"),
|
||||
Item::assistant_message("progress"),
|
||||
Item::tool_call("c1", "Read", "{\"file\":\"x\"}"),
|
||||
Item::tool_result("c1", "read ok"),
|
||||
],
|
||||
);
|
||||
|
||||
let overview = view.overview();
|
||||
assert_eq!(overview.len(), 2);
|
||||
assert_eq!(overview[0].kind, ReferenceKind::User);
|
||||
assert_eq!(overview[1].kind, ReferenceKind::Assistant);
|
||||
assert!(overview[1].text.contains("progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_filters_tool_input_and_output() {
|
||||
let view = SessionReferenceView::new(
|
||||
"segment-1",
|
||||
vec![
|
||||
Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"),
|
||||
Item::tool_result_with_content("c1", "read ok", "package metadata"),
|
||||
],
|
||||
);
|
||||
|
||||
let input_hits = view.search(&SearchOptions {
|
||||
query: "Cargo.toml".into(),
|
||||
kind: Some(ReferenceKind::Tool),
|
||||
tool_part: Some(ToolPart::Input),
|
||||
tool_name: Some("Read".into()),
|
||||
limit: None,
|
||||
min_entry_index: None,
|
||||
});
|
||||
assert_eq!(input_hits.len(), 1);
|
||||
assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input));
|
||||
|
||||
let output_hits = view.search(&SearchOptions {
|
||||
query: "package metadata".into(),
|
||||
kind: Some(ReferenceKind::Tool),
|
||||
tool_part: Some(ToolPart::Output),
|
||||
tool_name: None,
|
||||
limit: None,
|
||||
min_entry_index: None,
|
||||
});
|
||||
assert_eq!(output_hits.len(), 1);
|
||||
assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_by_entry_range_is_bounded_and_can_skip_tools() {
|
||||
let view = SessionReferenceView::new(
|
||||
"segment-1",
|
||||
vec![
|
||||
Item::user_message("one"),
|
||||
Item::tool_call("c1", "Read", "{}"),
|
||||
Item::tool_result_with_content("c1", "ok", "large-content".repeat(100)),
|
||||
Item::assistant_message("two"),
|
||||
],
|
||||
);
|
||||
|
||||
let result = view.read(
|
||||
ReadSelector::EntryRange([0, 3]),
|
||||
ReadOptions {
|
||||
include_tools: false,
|
||||
detail: ReadDetail::Compact,
|
||||
max_items: 10,
|
||||
max_bytes: 1_000,
|
||||
..ReadOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.entries.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| entry.kind != ReferenceKind::Tool)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_ref_uses_entry_range_and_evidence_id() {
|
||||
let view = SessionReferenceView::new("segment-1", vec![Item::user_message("hello")]);
|
||||
let source = view.source_ref_for("M0000").unwrap();
|
||||
assert_eq!(source.segment_id.as_deref(), Some("segment-1"));
|
||||
assert_eq!(source.entry_range, Some([0, 0]));
|
||||
assert_eq!(source.evidence_id.as_deref(), Some("M0000"));
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,9 @@ use manifest::{
|
|||
|
||||
use crate::compact::state::CompactState;
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::feature::builtin::TaskFeature;
|
||||
use crate::feature::builtin::{
|
||||
SessionExploreFeature, SessionExploreState, TaskFeature, render_extract_input,
|
||||
};
|
||||
use crate::feature::{FeatureRegistryBuilder, FeatureRegistryInstallReport};
|
||||
use crate::hook::{
|
||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
||||
|
|
@ -3130,8 +3132,47 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||
return Err(WorkerError::PromptCatalog(err));
|
||||
}
|
||||
};
|
||||
let ctx = Arc::new(extract::ExtractWorkerContext::new());
|
||||
let input_text = extract::build_extract_input(&items_to_extract);
|
||||
let source_segment_id = self.segment_state.segment_id();
|
||||
let source = memory::schema::SourceRef {
|
||||
segment_id: source_segment_id.to_string(),
|
||||
range: [start_entry as u64, end_entry as u64],
|
||||
};
|
||||
let session_view = crate::session_reference::SessionReferenceView::new(
|
||||
source_segment_id.to_string(),
|
||||
items_to_extract,
|
||||
);
|
||||
let session_explore_state = SessionExploreState::new(session_view, layout.clone(), source);
|
||||
let input_text = render_extract_input(session_explore_state.view());
|
||||
let mut internal_tools = Vec::new();
|
||||
let mut internal_hook_builder = HookRegistryBuilder::new();
|
||||
let feature_report = FeatureRegistryBuilder::new()
|
||||
.with_module(SessionExploreFeature::new(session_explore_state.clone()))
|
||||
.install_into_pending(&mut internal_tools, &mut internal_hook_builder);
|
||||
let installed_tool_names = feature_report.installed_tool_names();
|
||||
let expected_extract_tools = [
|
||||
"search_evidence",
|
||||
"read_evidence",
|
||||
"stage_candidate",
|
||||
"finish_extraction",
|
||||
];
|
||||
if !expected_extract_tools.iter().all(|name| {
|
||||
installed_tool_names
|
||||
.iter()
|
||||
.any(|installed| installed == name)
|
||||
}) {
|
||||
audit.emit(
|
||||
&layout,
|
||||
event_tx,
|
||||
memory::audit::WorkerLifecycleStatus::Failed,
|
||||
"session_explore_feature_install_failed",
|
||||
None,
|
||||
Some(extract_audit_base),
|
||||
None,
|
||||
);
|
||||
return Err(WorkerError::FeatureInstall(
|
||||
"session-explore feature install failed".to_string(),
|
||||
));
|
||||
}
|
||||
let internal_result = run_internal_worker(InternalWorkerSpec {
|
||||
slug: "memory-extract",
|
||||
system_prompt: extract_system_prompt,
|
||||
|
|
@ -3139,7 +3180,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||
client,
|
||||
cache_key: Some(self.segment_id().to_string()),
|
||||
max_turns: extract_worker_max_turns,
|
||||
tools: vec![extract::write_extracted_tool(ctx.clone())],
|
||||
tools: internal_tools,
|
||||
})
|
||||
.await;
|
||||
let usage = match internal_result {
|
||||
|
|
@ -3159,37 +3200,13 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
|||
}
|
||||
};
|
||||
|
||||
let payload = ctx.take_payload().unwrap_or_else(|| {
|
||||
let staging_results = session_explore_state.staged();
|
||||
if !session_explore_state.is_finished() {
|
||||
tracing::warn!(
|
||||
"extract worker did not call write_extracted; advancing pointer with empty payload"
|
||||
staged_count = staging_results.len(),
|
||||
"extract worker did not call finish_extraction; advancing pointer with staged output"
|
||||
);
|
||||
extract::ExtractedPayload::default()
|
||||
});
|
||||
|
||||
let source_segment_id = self.segment_state.segment_id();
|
||||
let staging_results = if payload.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let source = memory::schema::SourceRef {
|
||||
segment_id: source_segment_id.to_string(),
|
||||
range: [start_entry as u64, end_entry as u64],
|
||||
};
|
||||
match extract::write_staging(&layout, source, payload) {
|
||||
Ok(results) => results,
|
||||
Err(err) => {
|
||||
audit.emit(
|
||||
&layout,
|
||||
event_tx,
|
||||
memory::audit::WorkerLifecycleStatus::Failed,
|
||||
format!("staging_write_failed: {err}"),
|
||||
usage.clone(),
|
||||
Some(extract_audit_base),
|
||||
None,
|
||||
);
|
||||
return Err(WorkerError::ExtractStaging(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
let staging_id = staging_results
|
||||
.first()
|
||||
.map(|result| result.id.to_string())
|
||||
|
|
@ -4807,6 +4824,9 @@ pub enum WorkerError {
|
|||
#[error("memory extract staging write failed: {0}")]
|
||||
ExtractStaging(#[source] std::io::Error),
|
||||
|
||||
#[error("feature install failed: {0}")]
|
||||
FeatureInstall(String),
|
||||
|
||||
#[error("memory consolidation lock acquisition failed: {0}")]
|
||||
ConsolidationLock(#[source] memory::consolidate::LockError),
|
||||
|
||||
|
|
|
|||
|
|
@ -539,16 +539,14 @@ target = "./"
|
|||
permission = "write"
|
||||
"#;
|
||||
|
||||
fn write_extracted_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
|
||||
fn finish_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
|
||||
let input = serde_json::json!({
|
||||
"decisions": [],
|
||||
"discussions": [],
|
||||
"attempts": [],
|
||||
"requests": []
|
||||
"staged_count": 0,
|
||||
"no_candidates_reason": "test run has no durable candidates"
|
||||
})
|
||||
.to_string();
|
||||
vec![
|
||||
LlmEvent::tool_use_start(0, call_id, "write_extracted"),
|
||||
LlmEvent::tool_use_start(0, call_id, "finish_extraction"),
|
||||
LlmEvent::tool_input_delta(0, input),
|
||||
LlmEvent::tool_use_stop(0),
|
||||
LlmEvent::Status(StatusEvent {
|
||||
|
|
@ -561,13 +559,13 @@ fn write_extracted_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
|
|||
async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
|
||||
// Mock LLM responses, in call order:
|
||||
// [0] first run with usage(1000) so extract threshold (=1) fires.
|
||||
// [1] extract worker invokes write_extracted with empty payload.
|
||||
// [1] extract worker invokes finish_extraction with empty output.
|
||||
// [2] extract worker closes after the tool result.
|
||||
// [3] compact worker invokes write_summary.
|
||||
// [4] compact worker closes after the tool result.
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_extracted_tool_use_events("ec1"),
|
||||
finish_extraction_tool_use_events("ec1"),
|
||||
single_text_events("done"),
|
||||
write_summary_tool_use_events("sc1", "summary"),
|
||||
single_text_events("done"),
|
||||
|
|
@ -703,7 +701,7 @@ permission = "write"
|
|||
async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("recorded", 1000),
|
||||
write_extracted_tool_use_events("ec-large"),
|
||||
finish_extraction_tool_use_events("ec-large"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
||||
|
|
@ -724,7 +722,7 @@ async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
|
|||
async fn spawn_and_wait_drives_extract_to_completion() {
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_extracted_tool_use_events("ec1"),
|
||||
finish_extraction_tool_use_events("ec1"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
|
||||
|
|
@ -752,7 +750,7 @@ async fn detached_extract_does_not_fork_session_log() {
|
|||
// `ensure_head_or_fork` does not spawn a new session.
|
||||
let client = MockClient::new(vec![
|
||||
text_events_with_usage("hi", 1000),
|
||||
write_extracted_tool_use_events("ec1"),
|
||||
finish_extraction_tool_use_events("ec1"),
|
||||
single_text_events("done"),
|
||||
text_events_with_usage("ok", 1000),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::config::TicketConfig;
|
||||
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
||||
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ impl LocalProjectRecordReader {
|
|||
}
|
||||
|
||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||
let partial = self.ticket_backend.list_partial(TicketFilter::all())?;
|
||||
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
|
||||
let mut items = partial
|
||||
.tickets
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ use ticket::config::{
|
|||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
|
||||
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
|
|
@ -45,15 +46,11 @@ pub struct CreateOptions {
|
|||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ListState {
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
InProgress,
|
||||
Done,
|
||||
Closed,
|
||||
Active,
|
||||
All,
|
||||
States(Vec<TicketListState>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -381,13 +378,9 @@ fn list(
|
|||
options: ListOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let filter = match options.state {
|
||||
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning),
|
||||
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready),
|
||||
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued),
|
||||
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
|
||||
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
|
||||
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
|
||||
ListState::All => TicketFilter::all(),
|
||||
ListState::Active => TicketListQuery::active(),
|
||||
ListState::All => TicketListQuery::all(),
|
||||
ListState::States(states) => TicketListQuery::states(states),
|
||||
};
|
||||
let tickets = backend.list(filter)?;
|
||||
let count = tickets.len();
|
||||
|
|
@ -750,7 +743,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
|
|||
}
|
||||
|
||||
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
||||
let mut state = ListState::All;
|
||||
let mut state = ListState::Active;
|
||||
let mut limit = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
|
|
@ -1042,17 +1035,42 @@ fn option_with_value(
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> {
|
||||
match value {
|
||||
"planning" => Ok(ListState::Planning),
|
||||
"ready" => Ok(ListState::Ready),
|
||||
"queued" => Ok(ListState::Queued),
|
||||
"inprogress" => Ok(ListState::InProgress),
|
||||
"done" => Ok(ListState::Done),
|
||||
"closed" => Ok(ListState::Closed),
|
||||
"all" => Ok(ListState::All),
|
||||
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
|
||||
fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
|
||||
let tokens = raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if tokens.is_empty() {
|
||||
return Err(TicketCliError::new("--state must not be empty"));
|
||||
}
|
||||
if tokens.len() == 1 {
|
||||
match tokens[0] {
|
||||
"active" => return Ok(ListState::Active),
|
||||
"all" => return Ok(ListState::All),
|
||||
_ => {}
|
||||
}
|
||||
} else if tokens
|
||||
.iter()
|
||||
.any(|token| *token == "active" || *token == "all")
|
||||
{
|
||||
return Err(TicketCliError::new(
|
||||
"--state active/all cannot be mixed with workflow states",
|
||||
));
|
||||
}
|
||||
|
||||
let mut states = Vec::new();
|
||||
for token in tokens {
|
||||
let state = TicketListState::parse(token).ok_or_else(|| {
|
||||
TicketCliError::new(format!(
|
||||
"invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed"
|
||||
))
|
||||
})?;
|
||||
if !states.contains(&state) {
|
||||
states.push(state);
|
||||
}
|
||||
}
|
||||
Ok(ListState::States(states))
|
||||
}
|
||||
|
||||
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
|
||||
|
|
@ -1148,7 +1166,7 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1533,6 +1551,29 @@ mod tests {
|
|||
assert!(err.to_string().contains("use `yoi ticket close"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() {
|
||||
let default = parse_ticket_args(&args(&["list"])).unwrap();
|
||||
match default {
|
||||
TicketCli::Command(TicketCommand::List(options)) => {
|
||||
assert_eq!(options.state, ListState::Active)
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
|
||||
let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap();
|
||||
match explicit {
|
||||
TicketCli::Command(TicketCommand::List(options)) => assert_eq!(
|
||||
options.state,
|
||||
ListState::States(vec![TicketListState::Planning, TicketListState::Closed])
|
||||
),
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
|
||||
let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err();
|
||||
assert!(mixed.to_string().contains("cannot be mixed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_help_lists_required_commands() {
|
||||
let help = parse_ticket_args(&args(&["--help"])).unwrap();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import "./default.dcdl" // {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = false; };
|
||||
ticket = { enabled = false; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = false; };
|
||||
ticket = { enabled = true; thread = true; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import "./default.dcdl" // {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = true; };
|
||||
ticket = { enabled = false; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = false; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ feature = {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = true; };
|
||||
ticket = { enabled = false; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = false; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
};
|
||||
|
||||
memory = {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import "./default.dcdl" // {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = false; };
|
||||
ticket = { enabled = true; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = false; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; intake = true; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import "./default.dcdl" // {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = true; };
|
||||
ticket = { enabled = true; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = true; };
|
||||
ticket = { enabled = true; thread = true; orchestration_control = true; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import "./default.dcdl" // {
|
|||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
workers = { enabled = false; };
|
||||
ticket = { enabled = false; access = "lifecycle"; };
|
||||
ticket_orchestration = { enabled = false; };
|
||||
ticket = { enabled = true; thread = true; };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
You are a Yoi memory extract worker.
|
||||
|
||||
Your job is to read the supplied conversation slice and extract only memory candidates that may be worth later consolidation. Do not produce activity logs.
|
||||
Your job is to inspect the supplied host-created session reference view and stage only memory candidates that may be worth later consolidation. Do not produce activity logs.
|
||||
|
||||
## Language
|
||||
|
||||
|
|
@ -9,22 +9,39 @@ Your job is to read the supplied conversation slice and extract only memory cand
|
|||
- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is.
|
||||
- If the configured language is unclear, use English.
|
||||
|
||||
Call `write_extracted` exactly once with an object of this shape:
|
||||
## Tools
|
||||
|
||||
Use the session-explore tools only:
|
||||
|
||||
- `search_evidence`: find bounded evidence ids in the host-created session index. Optional `kind` accepts `user`, `assistant`/`agent`, `system`, or `tool`.
|
||||
- `read_evidence`: inspect a bounded evidence id or entry range before staging when the overview/index is not enough.
|
||||
- `stage_candidate`: write one flat staging record for one memory candidate.
|
||||
- `finish_extraction`: finish the run after all useful candidates are staged, or after deciding there are no useful candidates.
|
||||
|
||||
Do not invent evidence ids. Stage candidates only with `M...` or `T...` ids returned by `search_evidence` / `read_evidence` or shown in the initial evidence index. Overview `O...` ids are orientation labels, not source evidence ids.
|
||||
|
||||
Call `stage_candidate` once per useful candidate with this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"kind": "preference",
|
||||
"claim": "...",
|
||||
"why_useful": "...",
|
||||
"staleness": "...",
|
||||
"evidence_ids": []
|
||||
}
|
||||
]
|
||||
"evidence_ids": ["M0001"]
|
||||
}
|
||||
```
|
||||
|
||||
Then call `finish_extraction` exactly once:
|
||||
|
||||
```json
|
||||
{
|
||||
"staged_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
If nothing is worth staging, do not call `stage_candidate`; call `finish_extraction` with `{"staged_count": 0, "no_candidates_reason": "..."}`.
|
||||
|
||||
Allowed candidate kinds:
|
||||
|
||||
- `preference`: durable user/workspace preference or working style, not a one-off instruction.
|
||||
|
|
@ -39,11 +56,11 @@ Required fields per candidate:
|
|||
- `kind`: one of the allowed candidate kinds.
|
||||
- `claim`: concise statement of the candidate.
|
||||
- `why_useful`: why this candidate may be useful for future consolidation.
|
||||
- `evidence_ids`: one or more host-issued source evidence ids.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `staleness`: when this candidate should be revisited or invalidated.
|
||||
- `evidence_ids`: leave empty in this transitional path unless host-issued evidence ids are present.
|
||||
|
||||
Do not extract:
|
||||
|
||||
|
|
@ -58,6 +75,4 @@ Do not extract:
|
|||
- validation results unless they imply a reusable lesson, active blocker, or authority evidence;
|
||||
- implementation details that belong only in commit diff.
|
||||
|
||||
Prefer no candidates over noisy candidates. If nothing is worth staging, call `write_extracted` with `{"candidates": []}`.
|
||||
|
||||
Do not include record ids, source anchors, session metadata, free-form prose, or raw tool output content. The host attaches staging metadata mechanically.
|
||||
Prefer no candidates over noisy candidates. The host attaches staging metadata and bounded evidence mechanically.
|
||||
523
scripts/dev-workspace.sh
Executable file
523
scripts/dev-workspace.sh
Executable file
|
|
@ -0,0 +1,523 @@
|
|||
#!/usr/bin/env bash
|
||||
# Development process switcher for the workspace web/backend/runtime stack.
|
||||
#
|
||||
# Common usage:
|
||||
# scripts/dev-workspace.sh status # inspect managed pids and port listeners only
|
||||
# scripts/dev-workspace.sh restart # restart backend/runtime only; frontend is left running
|
||||
# scripts/dev-workspace.sh start # move runtime/backend/frontend listeners to this checkout
|
||||
# scripts/dev-workspace.sh stop # stop runtime/backend/frontend listeners
|
||||
#
|
||||
# Safety notes:
|
||||
# - start/stop/restart are detached by default and run after
|
||||
# YOI_DEV_ACTION_DELAY_SECONDS=60. This gives API/tool-call sessions time to
|
||||
# persist their result before backend/runtime processes are stopped.
|
||||
# - Use restart for normal backend/runtime code changes. It intentionally does
|
||||
# not touch the frontend dev server.
|
||||
# - Use start or stop when the frontend listener must also move between
|
||||
# worktrees; frontend binds to 0.0.0.0 by default for browser access.
|
||||
# - Avoid YOI_DEV_WORKSPACE_FOREGROUND=1 during active API sessions; it runs the
|
||||
# mutating action synchronously and can interrupt the session that invoked it.
|
||||
# - Check the printed scheduled_log after a detached action completes.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUNTIME_DIR="${YOI_DEV_RUNTIME_DIR:-$ROOT_DIR/.yoi/dev}"
|
||||
PID_DIR="$RUNTIME_DIR/pids"
|
||||
LOG_DIR="$RUNTIME_DIR/logs"
|
||||
|
||||
BACKEND_LISTEN="${YOI_DEV_BACKEND_LISTEN:-127.0.0.1:8787}"
|
||||
RUNTIME_BIND="${YOI_DEV_RUNTIME_BIND:-127.0.0.1:38800}"
|
||||
FRONTEND_HOST="${YOI_DEV_FRONTEND_HOST:-0.0.0.0}"
|
||||
FRONTEND_PORT="${YOI_DEV_FRONTEND_PORT:-5173}"
|
||||
|
||||
RUNTIME_ENABLED="${YOI_DEV_RUNTIME_ENABLED:-1}"
|
||||
ACTION_DELAY_SECONDS="${YOI_DEV_ACTION_DELAY_SECONDS:-60}"
|
||||
WORKDIR_ID="$(basename "$(dirname "$(dirname "$ROOT_DIR")")")"
|
||||
UNIT_PREFIX="${YOI_DEV_SYSTEMD_UNIT_PREFIX:-yoi-dev-$WORKDIR_ID}"
|
||||
USE_SYSTEMD="${YOI_DEV_USE_SYSTEMD:-1}"
|
||||
FOREGROUND_MODE="${YOI_DEV_WORKSPACE_FOREGROUND:-0}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <start|stop|restart|status>
|
||||
|
||||
Manage the local Yoi development stack for this checkout:
|
||||
runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND
|
||||
backend target/debug/yoi-workspace-server serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN
|
||||
frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace)
|
||||
|
||||
Actions:
|
||||
start schedule a detached job that stops existing listeners, then starts runtime, backend, and frontend from this checkout
|
||||
stop schedule a detached job that stops runtime, backend, and frontend
|
||||
restart schedule a detached job that stops/starts runtime and backend only; frontend is left untouched
|
||||
status print pidfile and port-listener status without mutating processes
|
||||
|
||||
By default, start/stop/restart return immediately and run in a fully detached nohup+setsid job after $ACTION_DELAY_SECONDS seconds.
|
||||
This keeps API/tool-call sessions intact while the backend/runtime are restarted. Set
|
||||
YOI_DEV_WORKSPACE_FOREGROUND=1 to run the mutating action synchronously.
|
||||
|
||||
Environment overrides:
|
||||
YOI_DEV_ACTION_DELAY_SECONDS=60 delay before detached mutating actions run
|
||||
YOI_DEV_WORKSPACE_FOREGROUND=1 run start/stop/restart synchronously instead of scheduling
|
||||
YOI_DEV_BACKEND_LISTEN=127.0.0.1:8787
|
||||
YOI_DEV_RUNTIME_BIND=127.0.0.1:38800
|
||||
YOI_DEV_RUNTIME_ENABLED=1 set to 0 to skip the standalone runtime process
|
||||
YOI_DEV_FRONTEND_HOST=0.0.0.0
|
||||
YOI_DEV_FRONTEND_PORT=5173
|
||||
YOI_DEV_RUNTIME_DIR=$ROOT_DIR/.yoi/dev
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[dev-workspace] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
run_cargo() {
|
||||
if command -v cc >/dev/null 2>&1 && command -v pkg-config >/dev/null 2>&1; then
|
||||
cargo "$@"
|
||||
return
|
||||
fi
|
||||
if command -v nix >/dev/null 2>&1 && [[ -f "$ROOT_DIR/flake.nix" ]]; then
|
||||
nix develop "$ROOT_DIR" -c cargo "$@"
|
||||
return
|
||||
fi
|
||||
cargo "$@"
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$PID_DIR" "$LOG_DIR"
|
||||
}
|
||||
|
||||
pid_file() {
|
||||
printf '%s/%s.pid' "$PID_DIR" "$1"
|
||||
}
|
||||
|
||||
systemd_unit_name() {
|
||||
local name="$1"
|
||||
printf '%s-%s.service' "$UNIT_PREFIX" "$name"
|
||||
}
|
||||
|
||||
systemd_available() {
|
||||
[[ "$USE_SYSTEMD" != "0" ]] || return 1
|
||||
command -v systemd-run >/dev/null 2>&1 || return 1
|
||||
systemctl --user is-system-running >/dev/null 2>&1 || return 1
|
||||
}
|
||||
|
||||
systemd_main_pid() {
|
||||
local name="$1"
|
||||
local unit
|
||||
unit="$(systemd_unit_name "$name")"
|
||||
systemctl --user show -P MainPID "$unit" 2>/dev/null | awk '$1 != "" && $1 != "0" { print $1; exit }'
|
||||
}
|
||||
|
||||
log_file() {
|
||||
printf '%s/%s.log' "$LOG_DIR" "$1"
|
||||
}
|
||||
|
||||
is_running() {
|
||||
local pid="$1"
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
service_pid() {
|
||||
local file
|
||||
file="$(pid_file "$1")"
|
||||
[[ -f "$file" ]] || return 1
|
||||
local pid
|
||||
pid="$(cat "$file")"
|
||||
is_running "$pid" || return 1
|
||||
printf '%s' "$pid"
|
||||
}
|
||||
|
||||
split_addr_port() {
|
||||
local value="$1"
|
||||
local port="${value##*:}"
|
||||
local host="${value%:*}"
|
||||
if [[ "$host" == "$value" || -z "$port" ]]; then
|
||||
printf 'invalid address:port value: %s\n' "$value" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\t%s\n' "$host" "$port"
|
||||
}
|
||||
|
||||
port_for_addr() {
|
||||
split_addr_port "$1" | awk '{ print $2 }'
|
||||
}
|
||||
|
||||
listener_pids_for_port() {
|
||||
local port="$1"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | sort -u
|
||||
return 0
|
||||
fi
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltnp "sport = :$port" 2>/dev/null \
|
||||
| sed -nE 's/.*pid=([0-9]+).*/\1/p' \
|
||||
| sort -u
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
stop_pid() {
|
||||
local pid="$1"
|
||||
local label="$2"
|
||||
is_running "$pid" || return 0
|
||||
|
||||
local pgid
|
||||
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
if [[ "$pgid" == "$pid" ]]; then
|
||||
log "stopping $label process group -$pid"
|
||||
kill -TERM "-$pid" 2>/dev/null || true
|
||||
else
|
||||
log "stopping $label pid $pid"
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
for _ in {1..50}; do
|
||||
is_running "$pid" || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
log "forcing $label pid $pid"
|
||||
if [[ "$pgid" == "$pid" ]]; then
|
||||
kill -KILL "-$pid" 2>/dev/null || true
|
||||
else
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
stop_managed_service() {
|
||||
local name="$1"
|
||||
local file unit pid
|
||||
file="$(pid_file "$name")"
|
||||
|
||||
if systemd_available; then
|
||||
unit="$(systemd_unit_name "$name")"
|
||||
if systemctl --user is-active --quiet "$unit" 2>/dev/null; then
|
||||
log "stopping $name systemd unit $unit"
|
||||
systemctl --user stop "$unit" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
pid="$(cat "$file")"
|
||||
if is_running "$pid"; then
|
||||
stop_pid "$pid" "$name"
|
||||
fi
|
||||
rm -f "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
stop_port_listeners() {
|
||||
local label="$1"
|
||||
local port="$2"
|
||||
local pids=()
|
||||
mapfile -t pids < <(listener_pids_for_port "$port" || true)
|
||||
if [[ "${#pids[@]}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
[[ -n "$pid" ]] || continue
|
||||
log "stopping existing $label listener on port $port pid $pid"
|
||||
# Unmanaged dev processes may share a process group with the caller's terminal
|
||||
# or pod; stop only the listener PID here. Managed processes started by this
|
||||
# script are stopped by process group via pidfiles above.
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
for _ in {1..50}; do
|
||||
mapfile -t pids < <(listener_pids_for_port "$port" || true)
|
||||
[[ "${#pids[@]}" -eq 0 ]] && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
[[ -n "$pid" ]] || continue
|
||||
log "forcing existing $label listener on port $port pid $pid"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
start_service() {
|
||||
local name="$1"
|
||||
local cwd="$2"
|
||||
local port="$3"
|
||||
shift 3
|
||||
|
||||
stop_managed_service "$name"
|
||||
stop_port_listeners "$name" "$port"
|
||||
|
||||
local logfile pidfile pid
|
||||
logfile="$(log_file "$name")"
|
||||
pidfile="$(pid_file "$name")"
|
||||
: >"$logfile"
|
||||
|
||||
if systemd_available; then
|
||||
local unit
|
||||
unit="$(systemd_unit_name "$name")"
|
||||
log "starting $name systemd unit $unit; log: $logfile"
|
||||
systemd-run --user --unit="$unit" --collect --same-dir --working-directory="$cwd" \
|
||||
--property="StandardOutput=append:$logfile" \
|
||||
--property="StandardError=append:$logfile" \
|
||||
--property="KillMode=control-group" \
|
||||
"$@" >/dev/null
|
||||
for _ in {1..50}; do
|
||||
pid="$(systemd_main_pid "$name" || true)"
|
||||
if [[ -n "$pid" ]]; then
|
||||
printf '%s\n' "$pid" >"$pidfile"
|
||||
log "$name systemd pid $pid"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
printf '%s\n' "$name systemd unit did not expose MainPID" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "starting $name; log: $logfile"
|
||||
(
|
||||
cd "$cwd"
|
||||
exec setsid "$@"
|
||||
) >"$logfile" 2>&1 &
|
||||
pid="$!"
|
||||
printf '%s\n' "$pid" >"$pidfile"
|
||||
log "$name pid $pid"
|
||||
}
|
||||
|
||||
|
||||
build_runtime_backend() {
|
||||
if [[ "$RUNTIME_ENABLED" != "0" ]]; then
|
||||
log "building runtime binary"
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
run_cargo build -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server
|
||||
)
|
||||
else
|
||||
log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0; skipping runtime build"
|
||||
fi
|
||||
|
||||
log "building backend binary"
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
run_cargo build -p yoi-workspace-server --bin yoi-workspace-server
|
||||
)
|
||||
}
|
||||
|
||||
start_runtime() {
|
||||
if [[ "$RUNTIME_ENABLED" == "0" ]]; then
|
||||
log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0"
|
||||
return 0
|
||||
fi
|
||||
local port runtime_bin
|
||||
port="$(port_for_addr "$RUNTIME_BIND")"
|
||||
runtime_bin="$ROOT_DIR/target/debug/worker-runtime-rest-server"
|
||||
if [[ ! -x "$runtime_bin" ]]; then
|
||||
printf 'runtime binary not found or not executable: %s\n' "$runtime_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
start_service runtime "$ROOT_DIR" "$port" \
|
||||
env RUST_BACKTRACE="${RUST_BACKTRACE:-1}" "$runtime_bin" --bind "$RUNTIME_BIND"
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
local port backend_bin
|
||||
port="$(port_for_addr "$BACKEND_LISTEN")"
|
||||
backend_bin="$ROOT_DIR/target/debug/yoi-workspace-server"
|
||||
if [[ ! -x "$backend_bin" ]]; then
|
||||
printf 'backend binary not found or not executable: %s\n' "$backend_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
start_service backend "$ROOT_DIR" "$port" \
|
||||
"$backend_bin" serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN"
|
||||
}
|
||||
|
||||
start_frontend() {
|
||||
local frontend_dir="$ROOT_DIR/web/workspace"
|
||||
start_service frontend "$frontend_dir" "$FRONTEND_PORT" \
|
||||
deno run -A npm:vite@7.2.7 dev --host "$FRONTEND_HOST" --port "$FRONTEND_PORT"
|
||||
}
|
||||
|
||||
stop_runtime_backend() {
|
||||
stop_managed_service backend
|
||||
stop_managed_service runtime
|
||||
stop_port_listeners backend "$(port_for_addr "$BACKEND_LISTEN")"
|
||||
if [[ "$RUNTIME_ENABLED" != "0" ]]; then
|
||||
stop_port_listeners runtime "$(port_for_addr "$RUNTIME_BIND")"
|
||||
fi
|
||||
}
|
||||
|
||||
start_runtime_backend() {
|
||||
build_runtime_backend
|
||||
start_runtime
|
||||
start_backend
|
||||
}
|
||||
|
||||
start_all() {
|
||||
ensure_dirs
|
||||
start_runtime_backend
|
||||
start_frontend
|
||||
}
|
||||
|
||||
stop_all() {
|
||||
ensure_dirs
|
||||
stop_managed_service frontend
|
||||
stop_runtime_backend
|
||||
stop_port_listeners frontend "$FRONTEND_PORT"
|
||||
}
|
||||
|
||||
restart_runtime_backend() {
|
||||
ensure_dirs
|
||||
log "restarting runtime/backend only; frontend is left untouched"
|
||||
stop_runtime_backend
|
||||
start_runtime_backend
|
||||
}
|
||||
|
||||
status_service() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local managed="-"
|
||||
local listeners="-"
|
||||
local unit="-"
|
||||
local systemd_pid=""
|
||||
if systemd_available; then
|
||||
unit="$(systemd_unit_name "$name")"
|
||||
systemd_pid="$(systemd_main_pid "$name" || true)"
|
||||
if [[ -n "$systemd_pid" ]]; then
|
||||
managed="$systemd_pid"
|
||||
fi
|
||||
fi
|
||||
if [[ "$managed" == "-" ]] && service_pid "$name" >/dev/null; then
|
||||
managed="$(service_pid "$name")"
|
||||
fi
|
||||
mapfile -t pids < <(listener_pids_for_port "$port" || true)
|
||||
if [[ "${#pids[@]}" -gt 0 ]]; then
|
||||
listeners="${pids[*]}"
|
||||
fi
|
||||
printf '%-8s managed_pid=%-8s port=%-6s listener_pids=%-12s unit=%s\n' "$name" "$managed" "$port" "$listeners" "$unit"
|
||||
}
|
||||
|
||||
|
||||
status_all() {
|
||||
ensure_dirs
|
||||
status_service runtime "$(port_for_addr "$RUNTIME_BIND")"
|
||||
status_service backend "$(port_for_addr "$BACKEND_LISTEN")"
|
||||
status_service frontend "$FRONTEND_PORT"
|
||||
}
|
||||
|
||||
schedule_detached_action() {
|
||||
local action="$1"
|
||||
ensure_dirs
|
||||
|
||||
local stamp job_log job_unit
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
job_log="$LOG_DIR/${action}-$stamp.job.log"
|
||||
job_unit="${UNIT_PREFIX}-${action}-${stamp}-job.service"
|
||||
|
||||
log "scheduling $action in ${ACTION_DELAY_SECONDS}s; log: $job_log"
|
||||
if systemd_available; then
|
||||
systemd-run --user --unit="$job_unit" --collect --working-directory="$ROOT_DIR" \
|
||||
--property="StandardOutput=append:$job_log" \
|
||||
--property="StandardError=append:$job_log" \
|
||||
bash -c '
|
||||
set -uo pipefail
|
||||
delay="$1"
|
||||
root="$2"
|
||||
action="$3"
|
||||
sleep "$delay"
|
||||
cd "$root"
|
||||
printf "[%s] dev-workspace %s starting\n" "$(date -Is)" "$action"
|
||||
YOI_DEV_WORKSPACE_FOREGROUND=1 "$root/scripts/dev-workspace.sh" "$action"
|
||||
status=$?
|
||||
printf "[%s] dev-workspace %s finished with status %s\n" "$(date -Is)" "$action" "$status"
|
||||
exit "$status"
|
||||
' dev-workspace-job "$ACTION_DELAY_SECONDS" "$ROOT_DIR" "$action" >/dev/null
|
||||
|
||||
local scheduled_pid
|
||||
scheduled_pid="$(systemctl --user show -P MainPID "$job_unit" 2>/dev/null | awk '$1 != "" && $1 != "0" { print $1; exit }' || true)"
|
||||
printf 'scheduled_action=%s\n' "$action"
|
||||
printf 'scheduled_unit=%s\n' "$job_unit"
|
||||
printf 'scheduled_pid=%s\n' "${scheduled_pid:--}"
|
||||
printf 'scheduled_after_seconds=%s\n' "$ACTION_DELAY_SECONDS"
|
||||
printf 'scheduled_log=%s\n' "$job_log"
|
||||
return 0
|
||||
fi
|
||||
|
||||
nohup setsid bash -c '
|
||||
set -uo pipefail
|
||||
delay="$1"
|
||||
root="$2"
|
||||
action="$3"
|
||||
sleep "$delay"
|
||||
cd "$root"
|
||||
printf "[%s] dev-workspace %s starting\n" "$(date -Is)" "$action"
|
||||
YOI_DEV_WORKSPACE_FOREGROUND=1 "$root/scripts/dev-workspace.sh" "$action"
|
||||
status=$?
|
||||
printf "[%s] dev-workspace %s finished with status %s\n" "$(date -Is)" "$action" "$status"
|
||||
exit "$status"
|
||||
' dev-workspace-job "$ACTION_DELAY_SECONDS" "$ROOT_DIR" "$action" >>"$job_log" 2>&1 < /dev/null &
|
||||
local scheduled_pid="$!"
|
||||
disown "$scheduled_pid" 2>/dev/null || true
|
||||
|
||||
printf 'scheduled_action=%s\n' "$action"
|
||||
printf 'scheduled_pid=%s\n' "$scheduled_pid"
|
||||
printf 'scheduled_after_seconds=%s\n' "$ACTION_DELAY_SECONDS"
|
||||
printf 'scheduled_log=%s\n' "$job_log"
|
||||
}
|
||||
|
||||
run_mutating_action() {
|
||||
local action="$1"
|
||||
if [[ "$FOREGROUND_MODE" != "1" ]]; then
|
||||
schedule_detached_action "$action"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$action" in
|
||||
start)
|
||||
start_all
|
||||
;;
|
||||
stop)
|
||||
stop_all
|
||||
;;
|
||||
restart)
|
||||
restart_runtime_backend
|
||||
;;
|
||||
*)
|
||||
printf 'unknown mutating action: %s\n' "$action" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
local action="${1:-}"
|
||||
case "$action" in
|
||||
start)
|
||||
run_mutating_action start
|
||||
;;
|
||||
stop)
|
||||
run_mutating_action stop
|
||||
;;
|
||||
restart)
|
||||
run_mutating_action restart
|
||||
;;
|
||||
status)
|
||||
status_all
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
"")
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
printf 'unknown action: %s\n\n' "$action" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue
Block a user