fix(api): start lifecycle subscriptions from live events

refs #1270
This commit is contained in:
Ogulcan Celik
2026-08-23 00:53:44 +03:00
parent c9601b9396
commit fcc4a267e3
8 changed files with 134 additions and 121 deletions
+1
View File
@@ -6,6 +6,7 @@
- Custom themes can now define separate light and dark color overrides when automatic theme switching is enabled. (#837, thanks @aneym)
### Fixed
- New lifecycle event subscriptions now stream only events emitted after subscription begins instead of replaying retained history. (#1270)
- Windows users whose endpoint security blocks the fileless PowerShell install command can now use a local `install.cmd` bootstrap; installer downloads use `curl.exe` while preserving package checksum verification. (#2751)
- Retained mouse selections now copy when Ctrl+C or Cmd+C arrives before a delayed mouse release instead of forwarding the copy shortcut to the pane. (#3100, thanks @moret)
- Removing a background worktree workspace no longer changes focus to its parent workspace. (#3098)
@@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50
`workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。
`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。取得後はリソースイベントを購読し、そのイベントでローカルキャッシュを更新してください。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。
`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。ブートストラップ中のイベントを取りこぼさないよう、まず別の接続で `events.subscribe` を開いて確認応答を待ち、そのストリームをバッファしながら `session.snapshot` を呼び出します。スナップショットを適用してから、バッファしたイベントを順番に適用し、そのままストリーミングを続けます。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。
CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。
@@ -635,7 +635,7 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス
}
```
最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。
最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。
ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。
タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。
@@ -118,11 +118,13 @@ Raw socket method names use dot notation:
`session.snapshot` returns a one-time bootstrap snapshot for clients that keep
their own local runtime cache. The response includes version/protocol metadata,
focused workspace/tab/pane ids, workspace records, tab records, pane records,
tab layout snapshots, and agent records. It is not a subscription; after reading
it, subscribe to resource events and update the local cache from those events.
Call `session.snapshot` again after reconnecting or when the local cache may be
stale. Attached worktree provenance is included on workspace records. Full repo
worktree discovery remains `worktree.list`.
tab layout snapshots, and agent records. It is not a subscription. To avoid a
bootstrap gap, first open `events.subscribe` on another connection and wait for
its acknowledgement. Buffer that stream while calling `session.snapshot`,
install the snapshot, then apply the buffered events in order and continue
streaming. Call `session.snapshot` again after reconnecting or when the local
cache may be stale. Attached worktree provenance is included on workspace
records. Full repo worktree discovery remains `worktree.list`.
From the CLI, `herdr api snapshot` prints the live `session.snapshot` response
as JSON for clients and agents that want a simple bootstrap command.
@@ -805,6 +807,8 @@ Subscribe to events when you need a long-lived stream:
```
The first response acknowledges the subscription. Later lines are pushed events.
Lifecycle subscriptions start when the request is accepted and do not replay
events retained before that point.
Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`,
@@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50
`workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。
`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅;读取后应订阅资源事件,并根据事件更新本地缓存。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。
`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。为避免引导期间漏掉事件,应先在另一个连接上打开 `events.subscribe` 并等待确认,在调用 `session.snapshot` 时缓冲该事件流,然后安装快照、按顺序应用缓冲的事件并继续接收事件。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。
CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。
@@ -631,7 +631,7 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏
}
```
第一个响应确认订阅。之后的行是推送的事件。
第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。
工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。
标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。
+18 -11
View File
@@ -691,21 +691,28 @@ fn stream_subscriptions(
event_hub: &EventHub,
running: &Arc<AtomicBool>,
) -> std::io::Result<()> {
let event_start_sequence = event_hub.current_sequence();
let mut subscriptions = Vec::with_capacity(params.subscriptions.len());
for (index, subscription) in params.subscriptions.into_iter().enumerate() {
let active =
match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) {
Ok(active) => active,
Err(response) => {
if let Err(err) = write_json_line(&mut stream, &response) {
if is_connection_closed_error(&err) {
return Ok(());
}
return Err(err);
let active = match ActiveSubscription::new(
subscription,
&request_id,
index,
api_tx,
event_hub,
event_start_sequence,
) {
Ok(active) => active,
Err(response) => {
if let Err(err) = write_json_line(&mut stream, &response) {
if is_connection_closed_error(&err) {
return Ok(());
}
return Ok(());
return Err(err);
}
};
return Ok(());
}
};
subscriptions.push(active);
}
+88 -99
View File
@@ -1,9 +1,9 @@
use regex::Regex;
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent,
PaneScrollChangedEvent, PaneScrollInfo, Request, Subscription, SubscriptionEventData,
SubscriptionEventEnvelope, SubscriptionEventKind,
ErrorBody, ErrorResponse, EventKind, Method, PaneAgentStatusChangedEvent,
PaneOutputMatchedEvent, PaneScrollChangedEvent, PaneScrollInfo, Request, Subscription,
SubscriptionEventData, SubscriptionEventEnvelope, SubscriptionEventKind,
};
use crate::api::server::{dispatch_to_app_with_timeout, APP_RESPONSE_TIMEOUT};
use crate::api::{ApiRequestSender, EventHub};
@@ -110,104 +110,54 @@ impl ActiveSubscription {
index: usize,
api_tx: &ApiRequestSender,
event_hub: &EventHub,
event_start_sequence: u64,
) -> Result<Self, ErrorResponse> {
let event_subscription = |event_kind| {
Self::Event(ActiveEventSubscription {
event_kind,
last_sequence: event_start_sequence,
})
};
match subscription {
Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceCreated,
last_sequence: 0,
})),
Subscription::WorkspaceUpdated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceUpdated,
last_sequence: 0,
})),
Subscription::WorkspaceMetadataUpdated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceMetadataUpdated,
last_sequence: 0,
})),
Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceRenamed,
last_sequence: 0,
})),
Subscription::WorkspaceMoved {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceMoved,
last_sequence: 0,
})),
Subscription::WorkspaceReordered {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceReordered,
last_sequence: 0,
})),
Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceClosed,
last_sequence: 0,
})),
Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceFocused,
last_sequence: 0,
})),
Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorktreeCreated,
last_sequence: 0,
})),
Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorktreeOpened,
last_sequence: 0,
})),
Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorktreeRemoved,
last_sequence: 0,
})),
Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabCreated,
last_sequence: 0,
})),
Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabClosed,
last_sequence: 0,
})),
Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabFocused,
last_sequence: 0,
})),
Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabRenamed,
last_sequence: 0,
})),
Subscription::TabMoved {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::TabMoved,
last_sequence: 0,
})),
Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneCreated,
last_sequence: 0,
})),
Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneClosed,
last_sequence: 0,
})),
Subscription::PaneUpdated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneUpdated,
last_sequence: 0,
})),
Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneFocused,
last_sequence: 0,
})),
Subscription::PaneMoved {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneMoved,
last_sequence: 0,
})),
Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneExited,
last_sequence: 0,
})),
Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneAgentDetected,
last_sequence: 0,
})),
Subscription::LayoutUpdated {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::LayoutUpdated,
last_sequence: 0,
})),
Subscription::WorkspaceCreated {} => {
Ok(event_subscription(EventKind::WorkspaceCreated))
}
Subscription::WorkspaceUpdated {} => {
Ok(event_subscription(EventKind::WorkspaceUpdated))
}
Subscription::WorkspaceMetadataUpdated {} => {
Ok(event_subscription(EventKind::WorkspaceMetadataUpdated))
}
Subscription::WorkspaceRenamed {} => {
Ok(event_subscription(EventKind::WorkspaceRenamed))
}
Subscription::WorkspaceMoved {} => Ok(event_subscription(EventKind::WorkspaceMoved)),
Subscription::WorkspaceReordered {} => {
Ok(event_subscription(EventKind::WorkspaceReordered))
}
Subscription::WorkspaceClosed {} => Ok(event_subscription(EventKind::WorkspaceClosed)),
Subscription::WorkspaceFocused {} => {
Ok(event_subscription(EventKind::WorkspaceFocused))
}
Subscription::WorktreeCreated {} => Ok(event_subscription(EventKind::WorktreeCreated)),
Subscription::WorktreeOpened {} => Ok(event_subscription(EventKind::WorktreeOpened)),
Subscription::WorktreeRemoved {} => Ok(event_subscription(EventKind::WorktreeRemoved)),
Subscription::TabCreated {} => Ok(event_subscription(EventKind::TabCreated)),
Subscription::TabClosed {} => Ok(event_subscription(EventKind::TabClosed)),
Subscription::TabFocused {} => Ok(event_subscription(EventKind::TabFocused)),
Subscription::TabRenamed {} => Ok(event_subscription(EventKind::TabRenamed)),
Subscription::TabMoved {} => Ok(event_subscription(EventKind::TabMoved)),
Subscription::PaneCreated {} => Ok(event_subscription(EventKind::PaneCreated)),
Subscription::PaneClosed {} => Ok(event_subscription(EventKind::PaneClosed)),
Subscription::PaneUpdated {} => Ok(event_subscription(EventKind::PaneUpdated)),
Subscription::PaneFocused {} => Ok(event_subscription(EventKind::PaneFocused)),
Subscription::PaneMoved {} => Ok(event_subscription(EventKind::PaneMoved)),
Subscription::PaneExited {} => Ok(event_subscription(EventKind::PaneExited)),
Subscription::PaneAgentDetected {} => {
Ok(event_subscription(EventKind::PaneAgentDetected))
}
Subscription::LayoutUpdated {} => Ok(event_subscription(EventKind::LayoutUpdated)),
Subscription::PaneOutputMatched {
pane_id,
source,
@@ -652,6 +602,15 @@ mod tests {
}
}
fn workspace_focused_event(workspace_id: &str) -> EventEnvelope {
EventEnvelope {
event: EventKind::WorkspaceFocused,
data: EventData::WorkspaceFocused {
workspace_id: workspace_id.into(),
},
}
}
fn pane_info_with_scroll(scroll: Option<PaneScrollInfo>) -> PaneInfo {
PaneInfo {
pane_id: "pane_1".into(),
@@ -676,6 +635,35 @@ mod tests {
}
}
#[test]
fn lifecycle_subscription_skips_history_but_keeps_setup_window_events() {
let event_hub = EventHub::default();
event_hub.push(workspace_focused_event("before_subscription"));
let event_start_sequence = event_hub.current_sequence();
event_hub.push(workspace_focused_event("during_setup"));
let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut subscription = ActiveSubscription::new(
Subscription::WorkspaceFocused {},
"test",
0,
&api_tx,
&event_hub,
event_start_sequence,
)
.expect("workspace focus subscription");
let setup_event = subscription
.poll(&api_tx, &event_hub)
.expect("setup-window event");
assert_eq!(setup_event["data"]["workspace_id"], "during_setup");
assert!(subscription.poll(&api_tx, &event_hub).is_none());
event_hub.push(workspace_focused_event("after_setup"));
let live_event = subscription.poll(&api_tx, &event_hub).expect("live event");
assert_eq!(live_event["data"]["workspace_id"], "after_setup");
}
#[test]
fn workspace_metadata_subscription_uses_dedicated_event_kind() {
let event_hub = EventHub::default();
@@ -686,6 +674,7 @@ mod tests {
0,
&api_tx,
&event_hub,
event_hub.current_sequence(),
)
.expect("workspace metadata subscription");
+8 -2
View File
@@ -674,8 +674,14 @@ pub(super) fn wait_for_event(
Ok(subscription) => subscription,
Err(response) => return Ok(Some(serde_json::to_string(&response).unwrap())),
};
let mut active = match ActiveSubscription::new(subscription, &request_id, 0, api_tx, event_hub)
{
let mut active = match ActiveSubscription::new(
subscription,
&request_id,
0,
api_tx,
event_hub,
event_hub.current_sequence(),
) {
Ok(active) => active,
Err(response) => return Ok(Some(serde_json::to_string(&response).unwrap())),
};
+6
View File
@@ -1494,6 +1494,12 @@ fn events_subscribe_streams_pane_split_and_close_events() {
let ack = reader.read_json_line(Duration::from_secs(2));
assert_eq!(ack["id"], "sub_life_b");
assert_eq!(ack["result"]["type"], "subscription_started");
assert!(
reader
.try_read_json_line(Duration::from_millis(250))
.is_none(),
"new subscription must not replay the root pane creation"
);
let split = send_request(
&socket_path,