Skip to content

Commit 54eebfc

Browse files
genednaclaude
andcommitted
feat(agent): W4 machine interface - agent workspace list|show
plan-20260714 Part C W4 residual (post hand-off scope): the read-only machine interface over the workspace registry that w4-s1/s2 landed. - New 'libra agent workspace' subcommand family (src/command/agent/workspace.rs) reading workspace_record through the existing WorkspaceStore::list_with_conn / get_with_conn: * list - keyset pagination per §C.14 (workspace_id ASC, default limit 50 capped at 500 via the store's own clamp, opaque next_cursor round-tripped verbatim) with repeatable --state filters (provisioning|active|releasing|released|orphaned; an invalid state is a usage error); * show <workspace-id> - one frozen schema-v1 record (kind, state, owner kind/id, task/session associations, base commit, branch, lease owner/fence/expiry, canonical path, timestamps); an unknown id fails with an actionable hint. Lease MUTATION is deliberately not exposed - it stays inside the AgentRuntime services per the W4 card. - JSON envelopes 'agent_workspaces' / 'agent_workspace' carry schema_version 1; human output is a compact table / field list; --quiet stays silent. - Test: command_test::agent_workspace_list_paginates_and_shows seeds three real linked-workspace acquisitions through WorkspaceStore and drives the CLI end-to-end (no-dup/no-loss cursor walk, state filters, frozen record key set, unknown-id and invalid-state refusals). - Docs: agent.md EN/zh synopsis + description section, COMPATIBILITY agent row, AGENT_EXAMPLES banner lines, CHANGELOG. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Eli Ma <eli@patch.sh>
1 parent 999be9a commit 54eebfc

8 files changed

Lines changed: 442 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## [Unreleased]
44

5+
### Added (Part C W4)
6+
7+
- **`libra agent workspace list|show` (plan-20260714 Part C W4 machine
8+
interface)**: the read-only surface over the W4 workspace registry —
9+
keyset-paginated `list` (`workspace_id` ASC, default limit 50 capped
10+
at 500, opaque `next_cursor`, repeatable `--state` filters) and a
11+
by-id `show`, both emitting frozen schema-v1 records (kind, state,
12+
owner, lease fence/expiry, canonical path, task/session
13+
associations). Lease mutation stays internal to the agent runtime
14+
services.
15+
516
### Fixed
617

718
### Added (PD-03)

COMPATIBILITY.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/commands/agent.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ libra agent checkpoint <subcommand>
1818
libra agent skill <subcommand>
1919
libra agent clean [--all]
2020
libra agent doctor [--repair]
21+
libra agent workspace list [--limit <n>] [--cursor <token>] [--state <state>]...
22+
libra agent workspace show <workspace-id>
2123
libra agent push [--remote <name>] [--force-rewrite]
2224
libra agent rpc <subcommand>
2325
```
@@ -28,6 +30,17 @@ libra agent rpc <subcommand>
2830
removes provider hooks, reports captured session/checkpoint state, exposes
2931
read-only diagnostics, and can push `refs/libra/traces` to a remote.
3032

33+
`libra agent workspace list|show` is the read-only machine interface over
34+
the workspace registry (`workspace_record`): every linked worktree, task
35+
worktree, or remote workspace an agent runtime has associated, with its
36+
lifecycle state (`provisioning`/`active`/`releasing`/`released`/
37+
`orphaned`), owner, lease fence/expiry, and canonical path. `list` uses
38+
keyset pagination (`workspace_id` ascending; default `--limit 50`, capped
39+
at 500; round-trip `next_cursor` verbatim) and accepts repeatable
40+
`--state` filters; `show <workspace-id>` returns one frozen
41+
schema-v1 record. Lease mutation is never exposed here — it stays inside
42+
the agent runtime's internal services.
43+
3144
The supported roster is `claude-code`, `codex` and `opencode` (first batch),
3245
and all three are hook-installable: `claude-code` writes `.claude/settings.json`,
3346
`codex` writes user-level `$CODEX_HOME/hooks.json` plus Libra-managed trust

docs/commands/zh-CN/agent.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ libra agent checkpoint <subcommand>
1818
libra agent skill <subcommand>
1919
libra agent clean [--all]
2020
libra agent doctor [--repair]
21+
libra agent workspace list [--limit <n>] [--cursor <token>] [--state <state>]...
22+
libra agent workspace show <workspace-id>
2123
libra agent push [--remote <name>] [--force-rewrite]
2224
libra agent rpc <subcommand>
2325
```
@@ -26,6 +28,15 @@ libra agent rpc <subcommand>
2628

2729
`libra agent` 管理 Libra 的外部代理捕获表面。它安装和移除提供商 hook,报告已捕获的 session/checkpoint 状态,暴露只读诊断,并可将 `refs/libra/traces` 推送到远程。
2830

31+
`libra agent workspace list|show` 是 workspace 注册表(`workspace_record`)的
32+
只读机器接口:agent runtime 关联过的每个 linked worktree、task worktree 或
33+
remote workspace,含生命周期状态(`provisioning`/`active`/`releasing`/
34+
`released`/`orphaned`)、owner、lease fence/到期与 canonical 路径。`list`
35+
走 keyset 分页(按 `workspace_id` 升序;默认 `--limit 50`,上限 500;
36+
`next_cursor` 原样回传),`--state` 可重复过滤;`show <workspace-id>` 返回
37+
冻结的 schema v1 单条记录。lease 变更不在此暴露——它属于 agent runtime 的
38+
内部服务。
39+
2940
支持的 roster 为 `claude-code``codex``opencode`(首批),三者均可安装 hook:`claude-code``.claude/settings.json``codex` 写用户级 `$CODEX_HOME/hooks.json` 并在 `$CODEX_HOME/config.toml` 写入 Libra 托管的 trust 条目(未受信的 Codex hook 会被静默跳过,trust 条目是安装的一部分);`opencode` 写 Libra 托管插件 `.opencode/plugin/libra-hooks.js`(注意:`opencode --pure` 会禁用包括捕获在内的全部外部插件)。`gemini` 已从支持 roster 降级为仅卸载通道:`libra agent remove gemini` 可移除历史安装的 Libra 托管 hook(幂等),已捕获会话保持可读;对它或其它非 roster 代理执行 `add`/`enable` 会返回可操作的 unsupported 错误。
3041

3142
## 子命令

src/command/agent/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ mod hooks;
2727
mod import;
2828
mod list;
2929
mod push;
30+
mod workspace;
3031
// `libra review` is a TOP-LEVEL command (AG-22; `Commands::Review` in
3132
// `src/cli.rs`); its implementation lives here so it can reuse the
3233
// AG-20 `pub(super)` pagination helpers in `checkpoint.rs`.
@@ -100,6 +101,8 @@ EXAMPLES:
100101
libra agent clean Drop temporary checkpoints from the most recent stopped session
101102
libra agent clean --all Drop temporary checkpoints from every stopped session
102103
libra agent doctor Diagnose hook installation and capture state
104+
libra agent workspace list List workspace records (keyset pagination)
105+
libra agent workspace show <workspace-id> Show one workspace record (lease, owner, path, state)
103106
libra agent push Push refs/libra/traces to the default remote
104107
libra agent push --remote origin Push refs/libra/traces to a named remote
105108
libra agent rpc list Discover libra-agent-<name> RPC binaries on PATH
@@ -176,6 +179,10 @@ pub enum AgentSubcommand {
176179
#[command(about = "Push refs/libra/traces to a remote")]
177180
Push(PushArgs),
178181

182+
/// Inspect the workspace registry (§C.8, read-only).
183+
#[command(subcommand, about = "Inspect workspace records (read-only)")]
184+
Workspace(workspace::WorkspaceSubcommand),
185+
179186
/// Internal hook entry point (called by hook configs installed by `enable`).
180187
#[command(subcommand, about = "Hook entry point", hide = true)]
181188
Hooks(hooks::AgentHooksSubcommand),
@@ -365,6 +372,7 @@ pub async fn execute_safe(args: AgentArgs, output: &OutputConfig) -> CliResult<(
365372
AgentSubcommand::Clean(cmd) => clean::execute_safe(cmd, output).await,
366373
AgentSubcommand::Doctor(cmd) => doctor::execute_safe(cmd, output).await,
367374
AgentSubcommand::Push(cmd) => push::execute_safe(cmd, output).await,
375+
AgentSubcommand::Workspace(cmd) => workspace::execute_safe(cmd, output).await,
368376
AgentSubcommand::Hooks(cmd) => hooks::execute_safe(cmd, output).await,
369377
AgentSubcommand::Rpc(cmd) => rpc::execute_safe(cmd, output).await,
370378
}

src/command/agent/workspace.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
//! `libra agent workspace …` — the read-only machine interface over the
2+
//! Part C W4 workspace registry (§C.8): keyset-paginated `list` and a
3+
//! by-id `show`. Lease mutation stays internal to the AgentRuntime
4+
//! services; this surface only OBSERVES `workspace_record`.
5+
6+
use clap::{Args, Subcommand};
7+
8+
use crate::{
9+
internal::{
10+
db::get_db_conn_instance,
11+
workspace::{WorkspaceListQuery, WorkspaceRecord, WorkspaceState, WorkspaceStore},
12+
},
13+
utils::{
14+
error::{CliError, CliResult},
15+
output::{OutputConfig, emit_json_data},
16+
},
17+
};
18+
19+
/// `schema_version` of the paged list / show JSON `data` payloads
20+
/// (additive evolution only — mirrors the AG-20 pagination precedent).
21+
const WORKSPACE_SCHEMA_VERSION: u32 = 1;
22+
23+
#[derive(Subcommand, Debug)]
24+
pub enum WorkspaceSubcommand {
25+
/// List workspace records, keyset-paginated (`workspace_id` ASC).
26+
#[command(about = "List workspace records (keyset pagination)")]
27+
List(WorkspaceListArgs),
28+
/// Show one workspace record by id.
29+
#[command(about = "Show one workspace record")]
30+
Show(WorkspaceShowArgs),
31+
}
32+
33+
#[derive(Args, Debug)]
34+
pub struct WorkspaceListArgs {
35+
/// Maximum rows to return (default 50, capped at 500).
36+
#[arg(long, value_name = "N")]
37+
pub limit: Option<u64>,
38+
/// Keyset cursor: the `next_cursor` value from the previous page,
39+
/// round-tripped verbatim.
40+
#[arg(long, value_name = "CURSOR")]
41+
pub cursor: Option<String>,
42+
/// Restrict to one or more states (repeatable):
43+
/// provisioning|active|releasing|released|orphaned.
44+
#[arg(long = "state", value_name = "STATE")]
45+
pub states: Vec<String>,
46+
}
47+
48+
#[derive(Args, Debug)]
49+
pub struct WorkspaceShowArgs {
50+
/// Workspace identifier from `agent workspace list`.
51+
#[arg(value_name = "WORKSPACE_ID")]
52+
pub workspace_id: String,
53+
}
54+
55+
pub async fn execute_safe(cmd: WorkspaceSubcommand, output: &OutputConfig) -> CliResult<()> {
56+
match cmd {
57+
WorkspaceSubcommand::List(args) => list(args, output).await,
58+
WorkspaceSubcommand::Show(args) => show(args, output).await,
59+
}
60+
}
61+
62+
fn parse_state(raw: &str) -> CliResult<WorkspaceState> {
63+
match raw.trim().to_ascii_lowercase().as_str() {
64+
"provisioning" => Ok(WorkspaceState::Provisioning),
65+
"active" => Ok(WorkspaceState::Active),
66+
"releasing" => Ok(WorkspaceState::Releasing),
67+
"released" => Ok(WorkspaceState::Released),
68+
"orphaned" => Ok(WorkspaceState::Orphaned),
69+
other => Err(CliError::command_usage(format!(
70+
"invalid --state '{other}': expected provisioning, active, releasing, \
71+
released, or orphaned"
72+
))),
73+
}
74+
}
75+
76+
fn record_json(record: &WorkspaceRecord) -> serde_json::Value {
77+
serde_json::json!({
78+
"workspace_id": record.workspace_id,
79+
"kind": record.kind.as_db_value(),
80+
"worktree_id": record.worktree_id,
81+
"path": record.path,
82+
"owner_kind": record.owner_kind.as_db_value(),
83+
"owner_id": record.owner_id,
84+
"task_id": record.task_id,
85+
"session_id": record.session_id,
86+
"base_commit": record.base_commit,
87+
"branch": record.branch,
88+
"state": record.state.as_db_value(),
89+
"lease_owner": record.lease_owner,
90+
"lease_fence": record.lease_fence,
91+
"lease_expires_at": record.lease_expires_at,
92+
"created_at": record.created_at,
93+
"updated_at": record.updated_at,
94+
})
95+
}
96+
97+
async fn list(args: WorkspaceListArgs, output: &OutputConfig) -> CliResult<()> {
98+
let states = if args.states.is_empty() {
99+
None
100+
} else {
101+
Some(
102+
args.states
103+
.iter()
104+
.map(|raw| parse_state(raw))
105+
.collect::<CliResult<Vec<_>>>()?,
106+
)
107+
};
108+
let query = WorkspaceListQuery {
109+
states,
110+
limit: args.limit,
111+
cursor: args.cursor,
112+
};
113+
let conn = get_db_conn_instance().await;
114+
let page = WorkspaceStore::list_with_conn(&conn, &query)
115+
.await
116+
.map_err(|error| CliError::fatal(format!("failed to list workspace records: {error}")))?;
117+
118+
if output.is_json() {
119+
let payload = serde_json::json!({
120+
"schema_version": WORKSPACE_SCHEMA_VERSION,
121+
"workspaces": page.items.iter().map(record_json).collect::<Vec<_>>(),
122+
"next_cursor": page.next_cursor,
123+
});
124+
return emit_json_data("agent_workspaces", &payload, output);
125+
}
126+
if output.quiet {
127+
return Ok(());
128+
}
129+
if page.items.is_empty() {
130+
println!("(no workspace records)");
131+
return Ok(());
132+
}
133+
for record in &page.items {
134+
println!(
135+
"{} {:12} {:9} {}",
136+
record.workspace_id,
137+
record.state.as_db_value(),
138+
record.kind.as_db_value(),
139+
record.path
140+
);
141+
}
142+
if let Some(cursor) = &page.next_cursor {
143+
println!("(more rows: --cursor {cursor})");
144+
}
145+
Ok(())
146+
}
147+
148+
async fn show(args: WorkspaceShowArgs, output: &OutputConfig) -> CliResult<()> {
149+
let conn = get_db_conn_instance().await;
150+
let record = WorkspaceStore::get_with_conn(&conn, &args.workspace_id)
151+
.await
152+
.map_err(|error| CliError::fatal(format!("failed to read workspace record: {error}")))?
153+
.ok_or_else(|| {
154+
CliError::fatal(format!(
155+
"no workspace matches id '{}'; list workspaces with `libra agent workspace list`",
156+
args.workspace_id
157+
))
158+
})?;
159+
160+
if output.is_json() {
161+
let payload = serde_json::json!({
162+
"schema_version": WORKSPACE_SCHEMA_VERSION,
163+
"workspace": record_json(&record),
164+
});
165+
return emit_json_data("agent_workspace", &payload, output);
166+
}
167+
if output.quiet {
168+
return Ok(());
169+
}
170+
println!("workspace_id: {}", record.workspace_id);
171+
println!("kind: {}", record.kind.as_db_value());
172+
println!("state: {}", record.state.as_db_value());
173+
println!("path: {}", record.path);
174+
if let Some(worktree_id) = &record.worktree_id {
175+
println!("worktree_id: {worktree_id}");
176+
}
177+
println!("owner_kind: {}", record.owner_kind.as_db_value());
178+
if let Some(owner_id) = &record.owner_id {
179+
println!("owner_id: {owner_id}");
180+
}
181+
if let Some(task_id) = &record.task_id {
182+
println!("task_id: {task_id}");
183+
}
184+
if let Some(session_id) = &record.session_id {
185+
println!("session_id: {session_id}");
186+
}
187+
if let Some(branch) = &record.branch {
188+
println!("branch: {branch}");
189+
}
190+
if let Some(base_commit) = &record.base_commit {
191+
println!("base_commit: {base_commit}");
192+
}
193+
if let Some(lease_owner) = &record.lease_owner {
194+
println!("lease_owner: {lease_owner} (fence {})", record.lease_fence);
195+
}
196+
if let Some(expires) = record.lease_expires_at {
197+
println!("lease_expires_at: {expires}");
198+
}
199+
println!("created_at: {}", record.created_at);
200+
println!("updated_at: {}", record.updated_at);
201+
Ok(())
202+
}

0 commit comments

Comments
 (0)